Author SHA1 Message Date
Michael WesemannandClaude Opus 5 fba15b7897 [mike@mwxm4]
The four read-only actions move out of the action menu onto the sheet's own
letters — e, h, y, w — and the menu keeps only what changes a machine. h logs
in as root by default, survives a ^C during the login, and y actually reaches
the clipboard and says what it put there.

* e h y w are keys of the detail sheet; the menu loses its last group, its
  separator and the guestItem/vsphereItem helpers. The "no address" reason the
  greyed-out entries carried is now hasAddress, said on the status line.
* defaultSSH is "ssh root@%h" — a template in ~/.gvmrc replaces it whole.
* holdTerminalSignals catches SIGINT and SIGQUIT while a child has the screen:
  in cooked mode the keystroke went to the whole foreground group and took gvm
  with it. Caught, not ignored — exec resets a caught signal to default in the
  child, while an ignored one is inherited and the ssh could not be aborted.
  interrupted() tells that keystroke from a fault, so the screen is no longer
  held for something somebody meant to do.
* toClipboard uses pbcopy/wl-copy/xclip/xsel where there is one and always
  sends OSC 52 as well; an ssh login uses the sequence alone. iTerm2 keeps
  OSC 52 behind a setting, which is why y appeared to do nothing. The status
  line now names what was copied — hostname or address — and which clipboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 08:51:18 +02:00
Michael Wesemann 4a5477bde7 [mike@mwxm4] 2026-09-08 17:15:05 +02:00
Michael Wesemann 137a799399 [mike@mwxm4] 2026-09-08 17:08:47 +02:00
Michael Wesemann b902059402 [mike@mwxm4] 2026-09-08 17:00:17 +02:00
Michael Wesemann dda9dc1e74 [mike@mwxm4] 2026-09-08 15:50:23 +02:00
Michael Wesemann d3fa1790a1 [mike@mwxm4] 2026-09-07 17:01:28 +02:00
36 changed files with 6276 additions and 178 deletions
+428 -34
View File
@@ -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,14 +36,47 @@ 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 |
@@ -46,8 +84,11 @@ finds it readable by others.
| *(nothing)* | browse the machines interactively (see below) |
| `vm` | the same, spelled out |
| `vm -l [-m <re>] [--sort <order>] [--reverse]` | print them instead; all vCenters unless `-v` names one |
| `vm -l --issues` | only the machines with something wrong with them |
| `vm -l --json` | the same listing as a JSON document |
| `snap -l <vm>` | list a machine's snapshots |
| `snap -n <vm>` | take a snapshot, name printed |
| `snap --old [-d <days>] [-m]` | every snapshot older than that, on every vCenter, optionally by mail |
| `snap -r <vm> -s <snap>` | remove one snapshot |
| `snap --revert <vm> -s <snap>` | put the machine back to that snapshot |
| `snap --removeall <vm>` | remove all of them |
@@ -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,10 +116,13 @@ help, as does anything gvm does not recognise:
line, so a name, an address, a host or "off" all work, and
the hit is picked out in the row
↑ ↓ PgUp PgDn move, Home/End for the ends
enter the machine's parameters: power, host, guest and tools,
cpu and memory in use, uptime, storage, guest filesystems,
network adapters, snapshots, uuid and moref
enter the machine's parameters: what is wrong with it and what is
being done to it, power, host, guest and tools, cpu and
memory in use, uptime, storage, guest filesystems, network
adapters, snapshots, uuid and moref
↑ ↓ in there scroll the sheet, esc/enter back to the list
e h y w in the sheet: recent events, ssh to the guest, copy what
ssh would connect to, open it in the vSphere client
The sheet is one line per thing worth knowing, values that belong together
joined with a middle dot and no section headings — an ordinary machine fits a
@@ -85,29 +132,55 @@ 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, address, host, vCPUs, CPU load, memory and
memory in use, and the guest's operating system. The two load figures are
percentages of what the machine is allowed to use and of what it has configured;
a machine that is not running has no load rather than a load of zero and shows a
dash. Past 75 % they turn yellow, past 90 % red.
The columns are name, vCenter, power, snapshots, address, host, vCPUs, CPU load,
memory and memory in use, and the guest's operating system. The two load figures
are percentages of what the machine is allowed to use and of what it has
configured; a machine that is not running has no load rather than a load of zero
and shows a dash. Past 75 % they turn yellow, past 90 % red.
The snapshot column is how many the machine is dragging along, aged by colour:
past a week the count turns yellow, past a month red — a month being also what
`snap --old` reports on, so a red count means "this machine is in that report".
A machine with none shows a dash. The count is what the column says and the age
is only how it is said, which is why the sheet spells the date out: a colour
cannot be read in a pipe.
Two columns are not always there, because they hold an exception rather than a
property, and thirteen characters of blank down two hundred rows is thirteen
characters spent on nothing:
* **TASK** appears while vCenter is doing something to any machine in the list —
a clone, a migration, a consolidation, with its progress — and is gone again
when nothing is going on. A column that turns up because somebody started a
clone is not the layout shifting about: it is the news.
* **WHY** takes the guest operating system's place in the issues list (`^w`,
`--issues`), where every row has a reason to be there — and the four figures
go with it, so that the reason has the width. It is the last column that
listing gives up rather than the first: in a list whose every row is there
because of it, dropping the reason first leaves a list of machines with no
reason showing on any of them.
A terminal too narrow for all of that gives columns up, least useful first: the
guest's operating system, then the host, then the address, then the vCPU count —
so what survives longest is what a glance is for. Each step only ever takes a
column away, never brings one back, so dragging a window narrower does not
rearrange the table. An eighty-column terminal keeps everything but the operating
system and the host.
guest's operating system, then the host, then the snapshot count, then the
address, then the vCPU count — so what survives longest is what a glance is for.
Each step only ever takes a column away, never brings one back, so dragging a
window narrower does not rearrange the table. An eighty-column terminal keeps
everything but the operating system, the host and the snapshot count: the name
column's minimum is one notch narrower than it reads in order to buy the address
its place there, and the count is the one column here that has somewhere else to
be said — `^w`, `--issues` and `snap --old` all name it and date it.
### Sorting
`^o` puts a legend on the status line and the next key picks the order, so the
list stays on screen while it rearranges itself:
sort: n·name p·power c·cpu% m·mem% s·size u·cpus v·vc h·host a·ip r·reverse
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why s·size u·cpus v·vc h·host a·ip r·reverse
Each order comes with its own direction, because that is what asking for it
means: by name is a to z, by processor load is the busiest first. `r` reverses
@@ -122,6 +195,35 @@ direction the order runs. Machines that compare equal stay in name order, so
flipping the direction on a screen full of identical figures does not reshuffle
them.
Thirteen choices are ninety-three columns, so a terminal narrower than that
gets them on two lines instead of one that runs off the right-hand edge, hiding
the very choices the legend exists to offer. They break where the meaning
breaks — what the machine is doing and what it wants doing to it, then what it
is made of and where it lives — and the second line takes the help line's row,
which describes keys that do nothing while a menu is waiting for one. Decided
at render time, so a window dragged wider gets the one line back:
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why
s·size u·cpus v·vc h·host a·ip r·reverse
Three of them are about the two columns that are new:
* `z` is by how many snapshots the machine is carrying, most first. Nought is a
figure here and not a missing one — nothing to clean up is a fact about the
machine — so a machine with none sorts where nought belongs: at the bottom
going down, at the top coming back up. The letter carries no mnemonic because
every letter that does was taken; `--sort snaps` spells it out.
* `o` is by the age of the machine's *oldest* snapshot, oldest first, which is
the order the housekeeping is done in — a different question from `#`, and the
more useful one: one snapshot from March wants attention before six from this
morning. A machine with no snapshots has no age and sorts to the bottom either
way round, the same as a stopped machine's load does.
* `w` is by what is wrong with the machine: broken above wants-a-look above
nothing to report, and within each the machine with the most to answer for
first. Sorting the reasons as text would put "alarm" above "disks need
consolidating" and mean nothing at all. Run the other way up it is the
machines that are fine, by name — a listing worth having too.
The order survives `^r`, and the selection follows the machine it was on. `gvm vm
-l --sort cpu% --reverse` takes the same orders by letter or by name.
@@ -130,6 +232,54 @@ v38 unreachable` — for as long as the list is open, and the reason is on the
status line when it opens. Only the servers whose machines are actually there are
named as holding them.
### The machines that want looking at
A list of two hundred machines is read by running the eye down it, which is
exactly the wrong way to find the three that are broken. `^w` narrows it to
those, and each one carries the reason in place of its guest operating system:
NAME VC PWR SNAP IP HOST WHY
old01 v108 on 1 10.0.0.31 esx02 /var 97 % full · no VMware Tools
db01 v308 on 3 10.0.0.12 esx01 disks need consolidating · snapshot base is 63 days old
win7 v38 on - - esx07 vCenter says yellow
The four figures — vCPUs, processor load, memory and memory in use — are not
there. A machine is in this list because something is wrong with it, and how
hard its processors happen to be working at this second says nothing about any
of the reasons: they would be four columns of arithmetic between the machine's
name and the answer to the question that was asked. They are one keystroke away
in the ordinary list, and on the machine's own sheet.
Nothing new is asked of the servers: this is a filter over the sweep that is
already on screen, so it costs a keystroke and no waiting. `^w` again gives the
whole list back, the typed filter still applies inside it — `^w web` is the
broken web servers — and the title says `issues only` for as long as it is on,
because a filtered list that does not say so is a lie told by omission. `^o w`
puts the worst of them at the top.
What counts as an issue is deliberately narrow, because a list that cries wolf
is one nobody opens:
| reason | |
| --- | --- |
| disconnected, orphaned, inaccessible | vCenter cannot see the machine properly |
| waiting for an answer in vCenter | a question nobody has answered; the machine is stopped until somebody does |
| disks need consolidating | deltas left behind by a snapshot removal that did not finish, growing quietly |
| alarm: *name* | what vCenter itself is complaining about, by the name somebody gave the alarm |
| vCenter says red / yellow | the rolled-up status, when no alarm came with it to explain it |
| no VMware Tools | and only while the machine is running |
| */var* 97 % full | a guest filesystem past 90 %, named with the figure |
| snapshot *name* is 63 days old | past a month, which is where the table's red begins |
Broken is red and wants-a-look is yellow, worst first — which matters because
the column is truncated from the right. An alarm somebody has acknowledged is
one a person has dealt with already and is not reported; a machine that is
switched off is not a fault; and the things that are only true of a running
machine are not held against a stopped one.
The same list prints: `gvm vm -l --issues`, which is the morning's glance and
the one worth a cron job.
Nothing acts on a machine from the table. Everything that changes one lives in
the machine's own sheet, which `⏎` opens — a row of a table of two hundred
machines is something the eye runs past, not something anyone has read. In the
@@ -137,8 +287,12 @@ sheet:
^a the action menu (see below)
^s take a snapshot: a name, then a confirmation
e recent events
h ssh to the guest, as root
y copy that name or address to the clipboard
w open in the vSphere client
Pressing either in the table says so rather than doing nothing visible.
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
`^s` takes two steps.
@@ -153,17 +307,66 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
name is on the status line, and a sheet that is open jumps to its snapshot
section so the new one is there to see.
### The sheet's four letters
The four letters are the things that change nothing, on the machine or on the
vCenter: they read its history, copy its address, open it somewhere else. They
are letters of the sheet rather than entries in the menu because nothing they do
needs thinking about first, and — unlike the table, whose filter swallows every
ordinary letter — the sheet has nothing else to do with them:
* **`e`** puts the machine's own recent events at the foot of its sheet and
scrolls down to them — why is this thing off, who rebooted it, what happened
at four this morning. `gvm log` is the whole vCenter over the last hour, which
is the right shape for a mail and the wrong one for that question. They are
fetched when they are asked for: opening a machine stays one call.
* **`h`** logs in to the guest as root — `ssh root@<name>` — by its own hostname
where it reports one and by its address otherwise. The terminal goes back to
what it was for as long as that lasts. `ssh = ssh -l someone %h` in `~/.gvmrc`
replaces that command whole, root and all; the target is always one argument
and never goes through a shell, because it is a name the guest chose for
itself. `^C` while it hangs on a machine that is not answering kills the login
and no more than that: gvm catches the signal for as long as the child has the
screen — the terminal is in its ordinary mode there, where the keystroke goes
to every process in the foreground group — and comes back to the sheet saying
the login was interrupted.
* **`y`** copies exactly what `h` would connect to — the hostname where the
guest reports one, the address otherwise — and the status line names which of
the two it was and which clipboard it went into, because a clipboard is
invisible and "copied" on its own is something one has to go and check.
Two routes, because neither alone is enough. `pbcopy` (or `wl-copy`, `xclip`,
`xsel`) is the one that always works where there is one, and the terminal's own
OSC 52 escape sequence is the only one that reaches the right machine from the
far end of an ssh login — where a local `pbcopy` would copy into the clipboard
of a machine nobody is sitting at. So the sequence is always sent and the
command is used as well where there is one; a login is recognised by
`SSH_CONNECTION`, and there the sequence is the whole story. It is also the
route a terminal is free to ignore — iTerm2 keeps it behind *Applications in
terminal may access clipboard*, tmux behind `set-clipboard` — which is why the
line says when it was the only one used.
* **`w`** opens the machine's page in the vSphere client. The link needs the
vCenter's instance UUID, which is the serverGuid that client puts in its URLs
and the one thing gvm cannot work out from the configuration; where there is no
browser to hand off to, the URL is said and copied instead.
`h` and `y` need somewhere to connect to: on a machine whose guest is not
reporting an address they say so on the status line rather than doing nothing.
### The action menu
In a machine's sheet, `^a` opens the menu for it. Above the choices it repeats
the few lines of the sheet the choice depends on — state, guest, hostname,
address — taken from the sheet itself, so the two cannot word the same fact
differently. On a terminal too short for both, those lines go and the choices
stay. Everything that changes a
machine lives there and nowhere else — the list is arrowed through and its filter
swallows every ordinary letter, so a hotkey that powered a machine off would sit
one fumbled control key away from an outage, and the sheet has to be opened first
anyway.
In a machine's sheet, `^a` opens the menu for it. It is everything that changes a
machine, and it lives there and nowhere else — the list is arrowed through and its
filter swallows every ordinary letter, so a hotkey that powered a machine off
would sit one fumbled control key away from an outage, and the sheet has to be
opened first anyway.
Above the choices the menu repeats the few lines of the sheet the choice depends
on — state, guest, hostname, address — taken from the sheet itself, so the two
cannot word the same fact differently. On a terminal too short for both, those
lines go one at a time, least useful first: the state stays longest because every
choice depends on it, then the address and the hostname, which say which machine
this is about.
n take a snapshot o power on
r revert to a snapshot ... s shut down the guest
@@ -173,8 +376,8 @@ anyway.
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 snapshots" — rather
than left out, and picking it anyway spells the reason out instead of running it.
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
@@ -216,9 +419,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`.
@@ -252,6 +459,151 @@ 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
@@ -265,6 +617,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:
| | |
@@ -275,7 +635,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
@@ -337,3 +699,35 @@ refused operation sends nothing, that an unavailable menu entry does not run whe
it is picked anyway, that only the exact machine name passes the confirmation,
and that removing one of two identically named snapshots removes the one that was
picked.
So are the judgements the reports are made of, which are the ones that would go
wrong quietly:
* every reason a machine can be in the issues list, one by one, and the ones
that must *not* put it there — a stopped machine, an acknowledged alarm, a
filesystem with room, this morning's snapshot, a status reported twice
* that a snapshot's size counts each delta once and not once per descendant,
which is what summing whole disk chains does
* that an unknown figure is a dash on screen and `null` in the document, for the
load, the address, the uptime, a datastore that cannot be reached and a
snapshot whose file layout could not be read
* that the ssh target is one argument and never shell code — it is a name the
guest chose for itself
* that `y` names what it copied and that an ssh login uses no local clipboard
tool — the tests say they are a login, which also keeps them off the clipboard
of whoever is running them
* that a `^C` during a login does not take gvm with it, and that the login still
dies of it: the signal is caught for as long as the child has the screen, and
catching is not ignoring — an ignored one would be inherited by the ssh
* that 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.
+157 -7
View File
@@ -51,8 +51,52 @@ func (m menuItem) isSeparator() bool { return m.key == 0 && m.label == "" }
// actions: what it is, and whether it is running. They are the facts the choice
// underneath depends on, and having them on the same screen means not having to
// remember them from the sheet one keystroke ago.
//
// In sheet order, which is the order they are shown in.
var menuFacts = []string{"state", "guest", "hostname", "address"}
// factOrder is which of them to keep when there is not room for all four —
// most worth keeping first, the same idea as the table's expendable columns.
//
// The state comes first because every choice below depends on it. The address
// and the hostname come next: they say which machine is about to be powered off,
// which is worth having in front of one. The guest's operating system decides
// nothing here.
var factOrder = []string{"state", "address", "hostname", "guest"}
// keepFacts is as many of the facts as fit, still in sheet order.
//
// Giving them up one at a time rather than all at once is what a short terminal
// gets out of this: below twenty rows the menu and all four facts no longer fit
// on the screen together, and none of them is a worse answer than three.
func keepFacts(info []sheetLine, room int) []sheetLine {
if room >= len(info) {
return info
}
if room <= 0 {
return nil
}
keep := map[string]bool{}
for _, label := range factOrder {
if len(keep) >= room {
break
}
for _, l := range info {
if l.label == label {
keep[label] = true
break
}
}
}
out := make([]sheetLine, 0, len(keep))
for _, l := range info {
if keep[l.label] {
out = append(out, l)
}
}
return out
}
// sheetPick takes named lines out of a sheet, in the order asked for, skipping
// the ones this machine has nothing to say about. The lines come from vmDetail
// rather than being formatted again here, so the menu and the sheet cannot end up
@@ -429,12 +473,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 +544,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 +602,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"})
}
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
b.write(sb.String())
@@ -702,3 +758,97 @@ func wrap(s string, width int) []string {
}
return out
}
// ------------------------------------------------------- the sheet's own keys
//
// The four below change nothing, on the machine or on the vCenter: they read its
// history, copy its address, open it somewhere else. That is why they are on
// letters of the sheet itself rather than in the action menu, which is for the
// things one has to be sure about before pressing.
// showEvents puts the machine's recent history at the foot of its sheet and
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
// machine is one call, and this is another.
func (b *browser) showEvents(r vmRow) {
b.working(SF("reading the events of %s ...", r.name))
lines, err := eventsOf(r)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.events, b.eventsOf = lines, r.id()
if len(lines) == 0 {
b.setStatus(colDim, "vCenter has no recent events for "+r.name)
} else {
b.setStatus(colInfo, SF("%s of %s", plural(len(lines), "event"), r.name))
}
b.openDetail() // rebuilt, so the sheet carries them
b.scrollToSection("events")
}
// hasAddress reports whether there is somewhere to connect to, and says so where
// the key was pressed when there is not. A guest that is not talking reports no
// address, and ssh or copy doing nothing at all would read as gvm having hung —
// the greyed-out menu entries these two replaced said as much in their own line.
func (b *browser) hasAddress(r vmRow) bool {
if r.sshTarget() != "" {
return true
}
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
return false
}
// sshTo logs in to the guest. The terminal goes back to what it was for as long
// as that lasts (guest.go), and the list is redrawn afterwards.
func (b *browser) sshTo(r vmRow) {
target := r.sshTarget()
argv := sshCommand(b.ssh, target)
err := b.runInTerminal(argv)
switch {
case err == nil:
b.setStatus(colDim, "back from "+target)
case interrupted(err):
// Ctrl-C during a login is somebody changing their mind, not a fault:
// gvm now survives it (holdTerminalSignals) and says so in the colour
// of an ordinary remark.
b.setStatus(colDim, "the login to "+target+" was interrupted")
default:
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
}
}
// copyAddress puts what `h` would connect to where the next paste will find it.
//
// What it says is longer than "copied web01.example" was, and deliberately: a
// clipboard is invisible, so the line has to name what went into it — the
// hostname or the address, since the sheet shows both — and which clipboard it
// is. Where the escape sequence was the only route it also says so, because that
// is the case where it may quietly not have arrived.
func (b *browser) copyAddress(r vmRow) {
target, kind := r.sshTargetIs()
if where := b.toClipboard(target); where != "" {
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
return
}
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
}
// openVSphere opens the machine's page in the vSphere client, and says the URL
// either way: a workstation with no browser to hand off to still gets the one
// thing that was wanted, and so does anyone running gvm over ssh.
func (b *browser) openVSphere(r vmRow) {
url := vsphereURL(r)
if url == "" {
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
return
}
if err := openURL(url); err != nil {
b.toClipboard(url) // the same two routes as `y`
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
return
}
b.setStatus(colInfo, "opened "+url)
}
+10 -3
View File
@@ -598,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
{"19", true}, // room for three of them, the hostname among them
{"18", false}, // room for two: the state and the address
{"13", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
@@ -742,6 +746,9 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
if strings.Contains(code, "wg.Wait()") {
continue // a WaitGroup, not a vCenter task
}
if strings.Contains(code, "cmd.Wait()") {
continue // an exec.Cmd — a browser being handed a URL (guest.go)
}
found++
// The one place a task may be waited on is inside waitTask, which
// gives it a deadline of its own.
+439 -50
View File
@@ -39,20 +39,29 @@ const (
// The screen's colours. The comment on each is the name mwxcol's fzf theme gives
// the same job, so the two stay in step.
var (
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
// colPrompt is every line at the foot of the screen that wants an answer:
// the sort legend, a yes/no question, the label in front of something being
// typed. One colour for all of them, whatever kind of question it is —
// yellow, which is the job mwxcol's own theme gives it — so that "gvm is
// waiting for me" is learned once, in one place and one tone, rather than
// being a thing to work out per screen. It is the same value as colWarn and
// a name of its own, because the two mean different things and only one of
// them may ever change.
colPrompt = cYellow.fg()
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
)
// What the columns and the sheet's values are coloured by. Every one of these is
@@ -73,8 +82,8 @@ var (
)
const (
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll ^a actions ^s snapshot esc/⏎ back ^c quit"
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
gutter = 2 // the pointer's two columns, in front of every row
colSep = 2
labelWidth = 12
@@ -89,6 +98,13 @@ type vmRow struct {
name string
host string // ESXi host, short
vm mo.VirtualMachine
// What the sweep learned about the machine besides its summary. Both are
// carried on the row rather than asked for when they are shown: a column
// that has to make a call to fill itself in is a column that makes the list
// slow in proportion to how many machines are on the screen.
snaps []snapEntry // its snapshots, as the sweep found them
task *runningTask // what vCenter is doing to it right now, if anything
}
// id is what makes this machine this machine. A name does not: two vCenters may
@@ -200,11 +216,95 @@ func loadColor(pct float64, known bool) string {
return colSize
}
// The ages at which a snapshot stops being today's work. A snapshot taken for
// this afternoon's patch run should be gone this evening; a week is where it
// stops being that and starts being something nobody remembers taking. Thirty
// days is also what `snap --old` reports on by default, so a red count in the
// table means exactly "this machine is in that report".
const (
snapStaleDays = 7
snapOldDays = 30
)
// snapCount is how many snapshots the sweep found on the machine.
func (r vmRow) snapCount() int { return len(r.snaps) }
// oldest is the snapshot that has been there longest, which is the one that
// decides whether the machine needs attention. false when it has none, or when
// none of them came back with a date.
func (r vmRow) oldest() (snapEntry, bool) {
var best snapEntry
found := false
for _, e := range r.snaps {
if e.when.IsZero() {
continue
}
if !found || e.when.Before(best.when) {
best, found = e, true
}
}
return best, found
}
// snapAge is the age of that snapshot in days, in the shape the sort orders and
// the colour thresholds want. A machine without snapshots has no age rather than
// an age of zero — nothing to clean up is not the same as cleaned up a moment ago.
func (r vmRow) snapAge() (float64, bool) {
e, ok := r.oldest()
if !ok {
return 0, false
}
d, ok := e.age()
if !ok {
return 0, false
}
return d.Hours() / 24, true
}
// snapCell is the count as the table shows it, and snapColor ages it: past a
// week the number turns yellow, past a month red. The count is what the column
// says and the colour is how urgent it is, which is why the sheet spells the
// date out — a colour cannot be read in a pipe.
func (r vmRow) snapCell() string {
if r.snapCount() == 0 {
return "-"
}
return Itoa(r.snapCount())
}
func (r vmRow) snapColor() string {
if r.snapCount() == 0 {
return colOff
}
days, ok := r.snapAge()
switch {
case !ok:
return colChosen // there are snapshots, but no date to judge them by
case days >= snapOldDays:
return colFull
case days >= snapStaleDays:
return colBusy
}
return colSize
}
// taskCell is what is being done to the machine, or nothing at all.
func (r vmRow) taskCell() string {
if r.task == nil {
return ""
}
return r.task.cell()
}
// haystack is what the filter matches against: everything on the line, so typing
// an address or a host name narrows the list just as well as a name does.
// an address or a host name narrows the list just as well as a name does — and
// nothing that is not, because the filter's hit is picked out in the row and a
// match on something invisible would leave a row in the list with no reason
// showing anywhere on it.
func (r vmRow) haystack() string {
return strings.ToLower(strings.Join([]string{
r.name, r.vc.Name, r.ip(), r.host, r.guestOS(), r.powerShort(),
r.taskCell(),
}, " "))
}
@@ -238,28 +338,104 @@ var browseColumns = []browseColumn{
// its place on a terminal of eighty.
{header: "NAME", width: 22, flex: true, color: fixed(colName),
cell: func(r vmRow) string { return r.name }},
{header: "VC", width: 4, color: fixed(colWhere), expendable: 8,
{header: "VC", width: 4, color: fixed(colWhere), expendable: 10,
cell: func(r vmRow) string { return r.vc.Name }},
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
// How many rollback points the machine is dragging along, aged by colour.
//
// It is given up early — before the address, which is the column the widths
// above were tuned around — because it is the one column here that has
// somewhere else to be said. A snapshot old enough to matter is in ^w, in
// `vm -l --issues` and in `snap --old`, all of which name it and date it; an
// address has no other home. So a terminal of eighty gives up the count and
// keeps the address, and the count is back from eighty-five.
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 3},
// The address is the widest thing here that is not a name, and it is a
// lookup field: on a terminal this narrow nobody is looking an address up,
// they are glancing at what is busy. So it goes before the small figures do.
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 3},
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 4},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
cell: func(r vmRow) string { return r.host }},
{header: "CPU", width: 3, color: fixed(colSize), expendable: 4,
{header: "CPU", width: 3, color: fixed(colSize), expendable: 5,
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
{header: "CPU%", width: 4, expendable: 7,
{header: "CPU%", width: 4, expendable: 9,
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 5},
{header: "MEM%", width: 4, expendable: 6,
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 6},
{header: "MEM%", width: 4, expendable: 8,
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
expendable: 1},
}
// taskColumn and whyColumn are the two columns that are not always there.
//
// Every other column holds a property of a machine and can say "-" when the
// machine has none. These two hold an exception, and an exception has no "-":
// a column that is blank down two hundred rows is thirteen characters of width
// spent on nothing. So they exist only while there is something in them — the
// task column when anything at all is being done on the cluster, the reason
// column only in the listing that is made of reasons (^i, --issues).
//
// A column appearing because somebody started a clone is not the layout
// shifting about: it is the news. What must not happen — a column coming back
// as the terminal is dragged *narrower* — is fitColumns' business, and this
// does not touch it.
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
cell: vmRow.taskCell, color: fixed(colBusy)}
// The reason outranks every other column but the machine's name and its power
// state: in a listing whose every row is there because of it, giving it up
// first — which is what inheriting the guest operating system's rank would have
// done — leaves a list of machines with no reason showing on any of them. It is
// still expendable rather than fixed, so that a terminal too narrow for it
// falls down the same ladder as everything else instead of into the one-column
// fallback.
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 11,
cell: vmRow.issueCell, color: vmRow.issueColor}
// The figures the issues listing leaves out. A machine is in that list because
// something is wrong with it, and how hard its processors happen to be working
// at this second says nothing about any of the reasons — it is four columns of
// arithmetic between the machine's name and the answer to the question that was
// asked. They are one keystroke away in the ordinary list, and on the machine's
// own sheet.
var issueHides = []string{"CPU", "CPU%", "MEM", "MEM%"}
// listColumns is the table for this particular listing: the standing columns,
// with the two conditional ones put in where they belong. The task goes next to
// the power state, because both answer "what is this machine doing"; the reason
// takes the guest operating system's place, which is the least read column there
// is and the only one wide enough to hold a sentence.
func listColumns(rows []vmRow, why bool) []browseColumn {
busy := false
for _, r := range rows {
if r.task != nil {
busy = true
break
}
}
out := make([]browseColumn, 0, len(browseColumns)+1)
for _, c := range browseColumns {
if why {
if c.header == "GUEST OS" {
out = append(out, whyColumn)
continue
}
if contains(issueHides, c.header) {
continue
}
}
out = append(out, c)
if busy && c.header == "PWR" {
out = append(out, taskColumn)
}
}
return out
}
// fitColumns decides which columns a terminal of this width shows: give up the
// least useful until what is left fits, then hand the width left over to the ones
// allowed to grow. The columns keep the order they are declared in; what changes
@@ -272,21 +448,26 @@ var browseColumns = []browseColumn{
// improvement — makes a narrower terminal show *more* columns than a wider one,
// and a column that appears as the window shrinks is worse than a column that is
// simply gone. Where a column deserves to survive longer, its rank says so.
func fitColumns(width int) []browseColumn {
shown := make([]bool, len(browseColumns))
func fitColumns(width int) []browseColumn { return fitColumnsOf(browseColumns, width) }
// fitColumnsOf is that, for a table that is not the standing one: the listing
// may have a task column in it, or a reason column instead of the guest's
// operating system (see listColumns).
func fitColumnsOf(cols []browseColumn, width int) []browseColumn {
shown := make([]bool, len(cols))
for i := range shown {
shown[i] = true
}
for shownWidth(shown) > width {
i := leastUseful(shown)
for shownWidth(cols, shown) > width {
i := leastUseful(cols, shown)
if i < 0 {
break // nothing left that may be given up
}
shown[i] = false
}
chosen := make([]browseColumn, 0, len(browseColumns))
for i, c := range browseColumns {
chosen := make([]browseColumn, 0, len(cols))
for i, c := range cols {
if shown[i] {
chosen = append(chosen, c)
}
@@ -295,7 +476,7 @@ func fitColumns(width int) []browseColumn {
// A terminal too narrow even for what may not be given up: the name, cut to
// whatever there is. Better one column of truth than a row that wraps.
if tableWidth(chosen) > width {
first := browseColumns[0]
first := cols[0]
first.width = max(width, 1)
return []browseColumn{first}
}
@@ -331,12 +512,12 @@ func tableWidth(cs []browseColumn) int {
return w
}
func shownWidth(shown []bool) int {
func shownWidth(cols []browseColumn, shown []bool) int {
n, w := 0, 0
for i, on := range shown {
if on {
n++
w += browseColumns[i].width
w += cols[i].width
}
}
if n == 0 {
@@ -347,10 +528,10 @@ func shownWidth(shown []bool) int {
// leastUseful is the column to give up next, or -1 when every one that is left
// may not be.
func leastUseful(shown []bool) int {
func leastUseful(cols []browseColumn, shown []bool) int {
at, worst := -1, 0
for i, on := range shown {
e := browseColumns[i].expendable
e := cols[i].expendable
if !on || e == 0 {
continue
}
@@ -381,6 +562,22 @@ type sweep struct {
lost []string // their names alone, for the title
}
// sweepProps is what one pass reads off every machine. It is one property
// collector call per vCenter whatever is in this list, so the question for each
// entry is not "does it cost a round trip" but "is it small enough to carry for
// every machine in the inventory".
//
// summary, guest the table and most of the sheet
// snapshot the snapshot column, the age report and --issues; a
// tree of names and dates, and empty for most machines
// triggeredAlarmState what vCenter itself is complaining about
// recentTask what is being done to the machine (tasks.go)
//
// layoutEx is deliberately not here: it lists every file of every machine, which
// is a great deal of wire for a figure only the snapshot report wants, and that
// report asks for it separately, for the machines that actually have snapshots.
var sweepProps = []string{"summary", "guest", "snapshot", "triggeredAlarmState", "recentTask"}
// gatherVMs asks every server at once and returns what came back. A server that
// does not answer costs a line in the report, not the listing.
func gatherVMs(targets []VCenter) (sweep, error) {
@@ -425,6 +622,11 @@ func gatherVMs(targets []VCenter) (sweep, error) {
}
return s.rows[a].vc.Name < s.rows[b].vc.Name
})
// Every sweep leaves the names behind for the shell to complete against
// (complete.go). Nothing reads them back but the completion, and it costs
// one small file write per run of gvm.
saveInventory(s.answered, s.rows)
return s, nil
}
@@ -437,7 +639,7 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
return nil, nil, err
}
vms, err := s.vms("summary", "guest")
vms, err := s.vms(sweepProps...)
if err != nil {
return nil, s, err
}
@@ -449,6 +651,8 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
for _, h := range hosts {
hostName[h.Reference()] = shortHost(h.Name)
}
busy := runningTasks(s, vms)
s.alarms = s.alarmNames(triggeredAlarms(vms))
rows := make([]vmRow, 0, len(vms))
for _, vm := range vms {
@@ -462,11 +666,33 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
host = n
}
}
rows = append(rows, vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm})
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm,
snaps: snapshotsIn(vm.Snapshot)}
if t, ok := busy[vm.Reference()]; ok {
r.task = &t
}
rows = append(rows, r)
}
return rows, s, nil
}
// triggeredAlarms is every alarm definition this sweep saw complaining, once
// each. Nothing is asked of the server when nothing is alarming, which is the
// ordinary case.
func triggeredAlarms(vms []mo.VirtualMachine) []types.ManagedObjectReference {
seen := map[types.ManagedObjectReference]bool{}
var out []types.ManagedObjectReference
for _, vm := range vms {
for _, a := range vm.TriggeredAlarmState {
if !seen[a.Alarm] {
seen[a.Alarm] = true
out = append(out, a.Alarm)
}
}
}
return out
}
func closeSessions(sessions []*session) {
for _, s := range sessions {
s.close()
@@ -482,8 +708,12 @@ type browser struct {
view []int // indexes into rows: what the filter left
filter string
sel int // index into view
scroll int
// issuesOnly narrows the list to the machines with something wrong with them
// (issues.go). It is a second filter rather than a mode of its own: the typed
// filter still applies inside it, so "^w web" is the broken web servers.
issuesOnly bool
sel int // index into view
scroll int
sortBy int // which of sortOrders the rows are in (sort.go)
sortDesc bool // that order reversed
@@ -491,6 +721,14 @@ type browser struct {
answered []string // the vCenters this list actually holds
lost []string // and the ones it does not, because they did not answer
// The events of one machine, once somebody has asked for them (actions.go).
// They are kept against the machine they belong to, so arrowing on to the
// next machine does not show it somebody else's history.
events []eventLine
eventsOf string // the id of the machine they are of
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
detail []sheetLine // non-nil while a machine's sheet is on screen
dtitle string
dvc string // the vCenter, in the title, in the colour its column has
@@ -520,7 +758,11 @@ type browser struct {
}
// browseVMs is the command: gather, then hand the terminal over to the loop.
func browseVMs(targets []VCenter, filter string) error {
//
// ssh is the command line the sheet's `h` runs, out of the configuration:
// the browser is handed it rather than reading it, so nothing in the interactive
// half has to know where settings come from.
func browseVMs(targets []VCenter, filter, ssh string) error {
// Asked before anything else: the inventory sweep is three logins and a few
// seconds, and there is no point spending either on a screen that does not
// exist. It also keeps a stray `gvm` in a pipe or under cron from touching
@@ -529,7 +771,7 @@ func browseVMs(targets []VCenter, filter string) error {
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
}
b := &browser{targets: targets, filter: filter}
b := &browser{targets: targets, filter: filter, ssh: ssh}
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
@@ -667,6 +909,8 @@ func (b *browser) listKey(k key) bool {
b.openDetail()
case keyCtrlO:
b.sortPrompt()
case keyCtrlW:
b.toggleIssues()
case keyCtrlA, keyCtrlS:
// Nothing acts on a machine from the list. The cursor sits on a row that
// is one line of a table, and a table of two hundred machines is read by
@@ -702,11 +946,19 @@ func (b *browser) detailKey(k key) {
switch k.special {
case keyEsc, keyEnter, keyBackspace, keyLeft:
b.detail, b.dscroll = nil, 0
// The events go with the visit, not with the machine: coming back to a
// sheet half an hour later and finding half-hour-old events under a
// label that says nothing about when they were read would be the one
// stale thing on an otherwise freshly read screen. They are one
// keystroke away again.
b.events, b.eventsOf = nil, ""
b.setStatus("", "")
case keyCtrlA:
b.openMenu()
case keyCtrlS:
b.snapshot()
case keyRune:
b.detailRune(k.r)
case keyUp:
b.dscroll = max(b.dscroll-1, 0)
case keyDown:
@@ -722,6 +974,32 @@ func (b *browser) detailKey(k key) {
}
}
// detailRune answers the sheet's own letters: the four things that only read the
// machine or point somewhere else at it. They are letters here rather than
// entries at the foot of the action menu because nothing they do needs thinking
// about first, and the sheet — unlike the list — has no filter to swallow them.
// A letter that means nothing here is ignored, not complained about.
func (b *browser) detailRune(r rune) {
row := b.current()
if row == nil {
return
}
switch r {
case 'e':
b.showEvents(*row)
case 'h':
if b.hasAddress(*row) {
b.sshTo(*row)
}
case 'y':
if b.hasAddress(*row) {
b.copyAddress(*row)
}
case 'w':
b.openVSphere(*row)
}
}
// refilter rebuilds the visible set. The selection stays on the machine it was
// on where that machine is still in the list, which is what makes typing a few
// letters and pressing enter feel like one motion.
@@ -734,6 +1012,9 @@ func (b *browser) refilter() {
needle := strings.ToLower(strings.TrimSpace(b.filter))
b.view = b.view[:0]
for i, r := range b.rows {
if b.issuesOnly && !r.hasIssues() {
continue
}
if needle == "" || strings.Contains(r.haystack(), needle) {
b.view = append(b.view, i)
}
@@ -750,6 +1031,35 @@ func (b *browser) refilter() {
}
}
// toggleIssues turns the issues filter on and off. Turning it on says how many
// machines there are to answer for, because none at all is the answer one hopes
// for and an empty screen on its own does not read as good news.
func (b *browser) toggleIssues() {
b.issuesOnly = !b.issuesOnly
b.refilter()
if !b.issuesOnly {
b.setStatus(colInfo, SF("all %d machines", len(b.rows)))
return
}
switch n := len(withIssues(b.rows)); n {
case 0:
b.setStatus(colOK, "nothing to report on any of them")
case 1:
b.setStatus(colWarn, "1 machine wants looking at")
default:
b.setStatus(colWarn, SF("%d machines want looking at", n))
}
}
// columns is the table as this list is showing it right now: the standing
// columns, a task column while anything is running, and the reason column in
// place of the guest's operating system while the issues filter is on.
//
// It is asked of every row, not of the ones the filter left, so that narrowing
// the list cannot make a column come and go under the cursor.
func (b *browser) columns() []browseColumn { return listColumns(b.rows, b.issuesOnly) }
func (b *browser) current() *vmRow {
if b.sel < 0 || b.sel >= len(b.view) {
return nil
@@ -940,12 +1250,17 @@ type prompt struct {
text string
hint string
col string
// more is a second line, drawn where the help line goes. Only the sort
// legend uses it: a menu of thirteen choices does not fit across eighty
// columns, and the help line underneath it is describing keys that do
// nothing while a menu is waiting for one.
more string
}
// ask puts one question on the status line and waits for a single key. Only "y"
// means yes — every other key, Esc and Ctrl-C included, means no.
func (b *browser) ask(question string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -976,7 +1291,7 @@ func segLine(sb *strings.Builder, cols int, segs ...seg) {
func (b *browser) renderList() {
cols, rows := termSize()
cs := fitColumns(cols - gutter)
cs := fitColumnsOf(b.columns(), cols-gutter)
const head = 3 // title, filter, column headers
const foot = 2 // status, help
@@ -1003,6 +1318,11 @@ func (b *browser) renderList() {
{colDim, " on "},
{colWhere, strings.Join(b.answered, ", ")}, // the colour the VC column has
}
// A filtered list that does not say so is a lie told by omission: the count
// in front of it is of every machine, and the rows underneath are not.
if b.issuesOnly {
title = append(title, seg{colDim, " "}, seg{colWarn, "issues only"})
}
if len(b.lost) > 0 {
title = append(title,
seg{colDim, " "},
@@ -1053,23 +1373,30 @@ func (b *browser) renderList() {
seg{colDim, b.prompt.hint})
case b.status != "":
segLine(&sb, cols, seg{b.statusCol, b.status})
case len(b.view) == 0 && b.issuesOnly && b.filter == "":
segLine(&sb, cols, seg{colOK, "nothing to report on any of them — ^w for all of them again"})
case len(b.view) == 0:
segLine(&sb, cols, seg{colDim, "nothing matches"})
default:
segLine(&sb, cols)
}
sb.WriteString(colDim + truncate(listHelp, cols) + attrOff + scrEOL)
help, helpCol := listHelp, colDim
if b.prompt != nil && b.prompt.more != "" {
help, helpCol = b.prompt.more, b.prompt.col
}
sb.WriteString(helpCol + truncate(help, cols) + attrOff + scrEOL)
b.parkCursor(&sb, cols, rows)
b.write(sb.String())
}
// editLine draws the input in the status area: the label stays put, the typed
// text is the query colour, and a hint says what Enter alone would do.
// editLine draws the input in the status area: the label stays put in the colour
// every question at the foot of the screen has, and the typed text is the query
// colour, because it is the answer and not part of the question.
func (b *browser) editLine(sb *strings.Builder, cols int) {
segLine(sb, cols,
seg{colWarn, b.edit.label},
seg{colQuery, string(b.edit.runes)})
seg{colPrompt, b.edit.label},
seg{colQuery, string(b.edit.runes)}) // the answer is the operator's, and white
}
// parkCursor puts the terminal's own cursor where the typing happens and shows
@@ -1276,9 +1603,31 @@ func (b *browser) openDetail() {
b.dwhere = SF(" · %s · %s", r.vc.Datacenter, r.host)
snaps, snapCol := snapshotLines(*r)
b.detail = vmDetail(*r, snaps, snapCol)
if b.eventsOf == r.id() {
b.detail = append(b.detail, eventSheet(b.events)...)
}
b.dscroll = 0
}
// eventSheet is the history as sheet lines: the label on the first, each line in
// the colour of its own severity. It is appended to the sheet rather than built
// into vmDetail because vmDetail asks nothing of the network and this is the one
// section that had to be fetched.
func eventSheet(events []eventLine) []sheetLine {
if len(events) == 0 {
return nil
}
out := make([]sheetLine, 0, len(events))
for i, e := range events {
label := ""
if i == 0 {
label = "events"
}
out = append(out, sheetLine{label: label, value: e.text, col: e.col})
}
return out
}
// vmDetail is the parameter sheet of one machine. snaps is what snapshotLines
// found, passed in rather than fetched here so that the sheet itself asks no
// questions and can be built from a machine that was never connected to.
@@ -1331,6 +1680,25 @@ func vmDetail(r vmRow, snaps []string, snapCol string) []sheetLine {
// eye should find without reading.
kv("state", join(state), r.powerColor())
// What is wrong with the machine, and what is being done to it: the two
// facts that were true a minute ago rather than since the machine was built,
// and the only reason anyone opens a sheet in a hurry. Each reason keeps its
// own colour — a filesystem at 97 % and a missing VMware Tools are not the
// same news, and painting the pair in one colour would say they were.
for i, is := range r.issueList() {
col, label := colBusy, ""
if is.bad {
col = colFull
}
if i == 0 {
label = "issues"
}
out = append(out, sheetLine{label: label, value: is.text, col: col})
}
if r.task != nil {
kv("task", r.task.line(), colBusy)
}
kv("guest", r.guestOS(), colValue)
if g := r.vm.Guest; g != nil {
kv("hostname", g.HostName, colValue)
@@ -1485,23 +1853,44 @@ func appendIf(list []string, values ...string) []string {
// snapshotLines asks this one machine for its snapshots, drawn as the tree they
// are. The colour comes back with them: a name someone chose is not the same kind
// of thing as an absence of one, or as a failure to look.
//
// Where the machine cannot be asked, what the sweep found is shown instead,
// with a line saying that is what it is. The alternative is a sheet reading
// "not read" beside a table that says the machine has three of them, which
// leaves the operator to work out which of the two to believe — and the answer
// would be "both": the sweep did find three, and this could not confirm it.
func snapshotLines(r vmRow) ([]string, string) {
if r.sess == nil {
return []string{"not read"}, colOff
return staleSnapshots(r, "not read: no connection to "+r.vc.Name)
}
entries, err := snapshotsOf(r.sess, r.ref)
if err != nil {
return []string{err.Error()}, colErr
return staleSnapshots(r, err.Error())
}
if len(entries) == 0 {
return []string{"none"}, colOff
}
return snapshotTree(entries), colChosen
}
// staleSnapshots is what the last sweep found, under the reason it could not be
// asked again. Nothing here is safe to act on — everything that removes or
// reverts a snapshot reads the tree itself, over a connection it has — so this
// is a report and says so.
func staleSnapshots(r vmRow, why string) ([]string, string) {
if len(r.snaps) == 0 {
return []string{why}, colOff
}
return append([]string{why + "; as the last sweep found them:"},
snapshotTree(r.snaps)...), colWarn
}
func snapshotTree(entries []snapEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.line()
}
return out, colChosen
return out
}
func uptime(d time.Duration) string {
+417 -6
View File
@@ -251,6 +251,71 @@ func TestListViewHasNoActionKeys(t *testing.T) {
}
}
// The four that only read a machine are the sheet's own letters now, not entries
// at the foot of the action menu. Each one has to reach its own action from
// there, and a letter that means nothing must be dropped rather than answered.
func TestSheetLettersReachTheirActions(t *testing.T) {
// Said to be an ssh login, which keeps the test off the clipboard of whoever
// is running it: with no local tool to hand, `y` uses the terminal's own
// escape sequence and nothing else.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
// That sequence goes to the terminal, so the sheet needs one to write to.
pipe := func(b *browser) func() string {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = w
return func() string {
w.Close()
out, _ := io.ReadAll(r)
r.Close()
return string(out)
}
}
// y copies. It is the one of the four that needs neither a session nor a
// browser, so it is checked all the way through.
b := testBrowser("web01")
read := pipe(b)
b.detailRune('y')
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
t.Errorf("y sent no clipboard sequence: %q", got)
}
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
// business; here it only has to name the thing that was copied.
for _, want := range []string{"clipboard", "web01.example"} {
if !strings.Contains(b.status, want) {
t.Errorf("y said %q, which does not mention %q", b.status, want)
}
}
// e and w need the connection the machine was read over, and these rows have
// none: what matters is that the letter arrived at the action, which says so.
for _, c := range []struct {
k rune
want string
}{{'e', "v308"}, {'w', "v308"}} {
b := testBrowser("web01")
read := pipe(b)
b.detailRune(c.k)
read()
if !strings.Contains(b.status, c.want) {
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
}
}
// A letter nothing is bound to is ignored — silently, because the sheet is
// not a filter and there is nothing to correct.
b = testBrowser("web01")
b.setStatus("", "")
b.detailRune('q')
if b.status != "" {
t.Errorf("an unbound letter said %q", b.status)
}
}
func TestDetailSheetHasTheParameters(t *testing.T) {
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
@@ -913,8 +978,9 @@ func TestColumnLadder(t *testing.T) {
// each rung are given, so a width change moves the boundary and not only the
// example.
const (
all = "NAME VC PWR IP HOST CPU CPU% MEM MEM% GUEST OS"
nine = "NAME VC PWR IP HOST CPU CPU% MEM MEM%"
all = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM% GUEST OS"
ten = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM%"
nine = "NAME VC PWR SNAP IP CPU CPU% MEM MEM%"
eight = "NAME VC PWR IP CPU CPU% MEM MEM%"
seven = "NAME VC PWR CPU CPU% MEM MEM%"
six = "NAME VC PWR CPU% MEM MEM%"
@@ -928,9 +994,12 @@ func TestColumnLadder(t *testing.T) {
width int
want string
}{
{200, all}, {109, all},
{108, nine}, {89, nine},
{88, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
{200, all}, {115, all},
{114, ten}, {95, ten},
{94, nine}, {83, nine},
// The snapshot count is what a terminal of eighty gives up to keep the
// address, which is what the name column's minimum was tuned for.
{82, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
{76, seven}, {60, seven},
{59, six}, {55, six},
{54, five}, {46, five},
@@ -1046,7 +1115,7 @@ func TestPipedListingCutsNothing(t *testing.T) {
testRow("web01", true, "10.0.0.6"),
}
out := captureStdout(t, func() { printList(rows) })
out := captureStdout(t, func() { printList(rows, listColumns(rows, false)) })
if !strings.Contains(out, long) {
t.Errorf("the long name was cut:\n%s", out)
@@ -1125,3 +1194,345 @@ func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
got.vc.Name, got.name)
}
}
// ---------------------------------------------------- the conditional columns
// The task column is there while anything is being done on the cluster and not
// otherwise. Every other column holds a property and can say "-"; this one
// holds an exception, and thirteen characters of blank down two hundred rows is
// thirteen characters spent on nothing.
func TestTheTaskColumnIsThereWhenThereIsATask(t *testing.T) {
quiet := []vmRow{testRow("web01", true, "10.0.0.5")}
if headers(listColumns(quiet, false)) != headers(browseColumns) {
t.Errorf("a quiet cluster shows %s", headers(listColumns(quiet, false)))
}
busy := []vmRow{testRow("web01", true, "10.0.0.5"), testRow("db01", true, "10.0.0.6")}
busy[1].task = &runningTask{what: "clone", progress: 40}
got := headers(listColumns(busy, false))
if !strings.Contains(got, "TASK") {
t.Fatalf("a busy cluster shows %s", got)
}
// Next to the power state: both answer "what is this machine doing".
if !strings.Contains(got, "PWR TASK") {
t.Errorf("the task column is not beside the power state: %s", got)
}
// And the machine that is not busy has an empty cell rather than a dash,
// which would read as a value of its own.
if cell := busy[0].taskCell(); cell != "" {
t.Errorf("an idle machine's task cell is %q", cell)
}
}
// In the listing that is made of reasons, the reason takes the place of the
// guest's operating system: the least read column, and the only one wide enough
// to hold a sentence.
func TestTheReasonColumnReplacesTheGuestOS(t *testing.T) {
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
got := headers(listColumns(rows, true))
if strings.Contains(got, "GUEST OS") {
t.Errorf("the issues listing still shows the guest os: %s", got)
}
if !strings.Contains(got, "WHY") {
t.Errorf("the issues listing has no reason column: %s", got)
}
// And the four figures go with it: how hard a machine's processors happen
// to be working says nothing about what is wrong with it, and they are four
// columns between its name and the answer.
for _, gone := range issueHides {
if strings.Contains(got, gone) {
t.Errorf("the issues listing still shows %s: %s", gone, got)
}
}
// What is left is what identifies the machine, plus the reason.
if want := "NAME VC PWR SNAP IP HOST WHY"; got != want {
t.Errorf("the issues listing shows\n %s\nwant\n %s", got, want)
}
}
// The ladder still only ever takes columns away, with the task column in the
// table as well: a column that comes back as the terminal is dragged narrower
// is worse than a column that is simply gone.
func TestTheLadderStillOnlyDropsWithATaskColumn(t *testing.T) {
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
rows[0].task = &runningTask{what: "clone"}
table := listColumns(rows, false)
var wider []string
for width := 10; width <= 220; width++ {
var here []string
for _, c := range fitColumnsOf(table, width) {
here = append(here, c.header)
}
for _, h := range wider {
if !slices.Contains(here, h) && h != "NAME" {
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
}
}
wider = here
}
}
// A terminal of eighty keeps the address, which is what the name column's
// minimum was tuned for. The snapshot count is what it gives up instead.
func TestATerminalOfEightyStillKeepsTheAddress(t *testing.T) {
got := headers(fitColumns(78))
if !strings.Contains(got, "IP") {
t.Errorf("a terminal of eighty shows %s", got)
}
if strings.Contains(got, "SNAP") {
t.Errorf("a terminal of eighty shows the snapshot count as well: %s", got)
}
if !strings.Contains(headers(fitColumns(83)), "SNAP") {
t.Errorf("a wider terminal does not show it either: %s", headers(fitColumns(83)))
}
}
func headers(cs []browseColumn) string {
var h []string
for _, c := range cs {
h = append(h, c.header)
}
return strings.Join(h, " ")
}
// The count is what the column says and its age is the colour it is said in.
// A colour cannot be read in a pipe, which is why the sheet spells the date out.
func TestTheSnapshotColumn(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if r.snapCell() != "-" || r.snapColor() != colOff {
t.Errorf("a machine with no snapshots shows %q", r.snapCell())
}
for _, c := range []struct {
days int
want string
}{
{1, colSize}, {snapStaleDays, colBusy}, {snapOldDays, colFull},
} {
r.snaps = []snapEntry{aged("s", c.days)}
if r.snapColor() != c.want {
t.Errorf("one snapshot of %d days is coloured wrongly", c.days)
}
if r.snapCell() != "1" {
t.Errorf("one snapshot shows as %q", r.snapCell())
}
}
// The oldest is what decides, not the newest or the first in the tree.
r.snaps = []snapEntry{aged("new", 1), aged("old", 200), aged("middling", 20)}
if r.snapCell() != "3" {
t.Errorf("three snapshots show as %q", r.snapCell())
}
if r.snapColor() != colFull {
t.Error("a machine whose oldest snapshot is 200 days old is not painted red")
}
if e, ok := r.oldest(); !ok || e.name != "old" {
t.Errorf("the oldest snapshot came back as %v", e.name)
}
if days, ok := r.snapAge(); !ok || int(days) != 200 {
t.Errorf("the age came back as %v", days)
}
// Snapshots with no date at all: there is something to clean up, but
// nothing to judge it by, so it is neither yellow nor red.
r.snaps = []snapEntry{{name: "undated"}}
if _, ok := r.snapAge(); ok {
t.Error("an undated snapshot reported an age")
}
if r.snapColor() != colChosen {
t.Error("an undated snapshot is aged as though it had a date")
}
}
// The sheet says what the table paints: what is wrong with the machine and what
// is being done to it, above everything that has been true since it was built.
func TestTheSheetLeadsWithWhatIsWrong(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true
r.task = &runningTask{what: "consolid", progress: 12}
var labels []string
var issues, task string
for _, l := range vmDetail(r, nil, "") {
if l.label != "" {
labels = append(labels, l.label)
}
if l.label == "issues" {
issues = l.value
}
if l.label == "task" {
task = l.value
}
}
if !strings.Contains(issues, "consolidating") {
t.Errorf("the sheet's issues line reads %q", issues)
}
if !strings.Contains(task, "consolid") || !strings.Contains(task, "12") {
t.Errorf("the sheet's task line reads %q", task)
}
order := strings.Join(labels, " ")
if !strings.HasPrefix(order, "state issues task guest") {
t.Errorf("the sheet begins %q", order)
}
}
// Each reason keeps its own colour: a filesystem at 97 % and a missing VMware
// Tools are not the same news.
func TestTheSheetColoursEachReasonForItself(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true // red
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // yellow
var cols []string
for _, l := range vmDetail(r, nil, "") {
if l.label == "issues" || (len(cols) > 0 && l.label == "") {
if l.col == colFull || l.col == colBusy {
cols = append(cols, l.col)
continue
}
break
}
}
if len(cols) != 2 || cols[0] != colFull || cols[1] != colBusy {
t.Errorf("the reasons came out coloured %v", cols)
}
}
// Every screen, drawn with a machine that has something to say on all of them:
// what is wrong with it, what is being done to it, snapshots, and a history
// that has been fetched. It is the check that each screen actually shows what
// it was given — the reason column was once first in the queue to be given up,
// which left a listing of nothing but machines with the reasons cut off.
func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
t.Setenv("COLUMNS", "120")
t.Setenv("LINES", "30")
b := testBrowser("web01", "db01")
b.answered = []string{"v308"}
b.rows[1].task = &runningTask{what: "clone", progress: 40}
b.rows[1].snaps = []snapEntry{aged("base", 63), aged("hotfix", 3)}
b.rows[1].vm.Summary.Runtime.ConsolidationNeeded = true
b.applySort()
list := stripEscapes(renderToPipe(t, b, b.renderList))
for _, want := range []string{"TASK", "clone 40%", "SNAP", "db01"} {
if !strings.Contains(list, want) {
t.Errorf("the list does not show %q:\n%s", want, list)
}
}
b.issuesOnly = true
b.refilter()
issues := stripEscapes(renderToPipe(t, b, b.renderList))
for _, want := range []string{"WHY", "consolidating", "issues only"} {
if !strings.Contains(issues, want) {
t.Errorf("the issues listing does not show %q:\n%s", want, issues)
}
}
if strings.Contains(issues, "web01") {
t.Errorf("the issues listing kept a healthy machine:\n%s", issues)
}
b.issuesOnly = false
b.refilter()
for i, r := range b.rows {
if r.name == "db01" {
b.sel = i
}
}
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
b.eventsOf = b.current().id()
b.openDetail()
sheet := stripEscapes(renderToPipe(t, b, b.renderDetail))
for _, want := range []string{"issues", "consolidating", "task", "clone", "events", "esx03"} {
if !strings.Contains(sheet, want) {
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
}
}
// The four that only read the machine are the sheet's own letters, so the
// sheet is where they have to be advertised.
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
if !strings.Contains(sheet, want) {
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
}
}
b.menu = b.buildMenu(*b.current(), b.current().snaps)
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
for _, want := range []string{"take a snapshot", "power off", "reset"} {
if !strings.Contains(menu, want) {
t.Errorf("the menu does not show %q:\n%s", want, menu)
}
}
// And the menu is now only the things that change a machine.
for _, gone := range []string{"recent events", "ssh to the guest",
"copy the address", "vSphere client"} {
if strings.Contains(menu, gone) {
t.Errorf("the menu still holds %q:\n%s", gone, menu)
}
}
}
// Every line at the foot of the screen that wants an answer wears one colour.
// The sort legend is a menu, a confirmation is a yes/no question and a snapshot
// name is something typed, and they are three different kinds of question — but
// "gvm is waiting for me" is one thing, and it is learned once rather than
// worked out per screen.
func TestEveryQuestionWearsOneColour(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "12")
b := testBrowser("web01", "db01")
b.applySort()
for _, c := range []struct {
what string
set func()
text string
lines int // how many of the bottom rows the question occupies
}{
{"the sort legend", func() {
// Narrower than the whole legend, so it is the two-line one: the
// second line is the part that could quietly lose its colour.
lines := sortLegend(60)
b.prompt = &prompt{text: lines[0], col: colPrompt, more: lines[1]}
b.edit = nil
}, "sort:", 2},
{"a yes/no question", func() {
b.prompt = &prompt{text: "power on web01 on v308?", col: colPrompt}
b.edit = nil
}, "power on web01", 1},
{"a name being typed", func() {
b.prompt = nil
b.edit = &editor{label: "name the snapshot of web01: "}
}, "name the snapshot", 1},
} {
c.set()
frame := renderToPipe(t, b, b.renderList)
if !strings.Contains(frame, colPrompt+c.text) {
t.Errorf("%s is not in the colour every question has:\n%s",
c.what, frame[max(len(frame)-400, 0):])
}
// And the second line of a two-line question is in it as well, or the
// menu would fade out halfway down.
if c.lines == 2 {
second := stripEscapes(frame)
if !strings.Contains(second, "r·reverse") {
t.Errorf("%s lost its second line:\n%s", c.what, second)
}
if strings.Count(frame, colPrompt) < 2 {
t.Errorf("%s does not carry the colour onto its second line", c.what)
}
}
}
b.prompt, b.edit = nil, nil
}
+359
View File
@@ -0,0 +1,359 @@
// complete.go — shell completion, and the inventory cache behind it.
//
// The names one types at gvm are machine names, and they are long, and there
// are hundreds of them on three servers. Completing them is what makes the
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
// be done by asking the vCenters: a shell completion runs on every Tab and has
// to answer in milliseconds, and three logins take seconds.
//
// So it answers out of what gvm last saw. Every sweep of the machine list
// leaves the names behind in the cache directory, per vCenter and with the time
// on them, and `--complete-vms` reads that file and nothing else. The cache is
// therefore always exactly as fresh as the last time somebody looked at the
// list — which is the right currency for a Tab key, and no currency at all for
// anything that acts on a machine. Nothing else in gvm reads this file: every
// command resolves the name it was given against the server itself.
//
// `gvm config` says how old it is, because a completion that quietly offers a
// machine deleted last month is a small mystery worth being able to explain.
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/integrii/flaggy"
)
// completionFlagNames are the options answered before the flag parser, the same
// way the update options are (gvm.go). They are what the generated scripts call
// on every Tab, so they must work on a machine whose configuration is broken —
// and must never print anything but the candidates. Nobody types them, which is
// why they are deliberately not in the help.
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
// isCompletionFlag reports whether this argument is one of them, so that the
// help can be checked against what is actually answered — the same guard the
// update options have (see the tests).
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
// completionFlags answers those options and reports whether it did.
func completionFlags() bool {
args := os.Args[1:]
for i, a := range args {
if !contains(completionFlagNames, a) {
continue
}
// The word after the option, skipping the "--" the completion scripts
// put in front of it so that a prefix beginning with a dash cannot be
// taken for an option of gvm's own.
rest := ""
for _, a := range args[i+1:] {
if a == "--" {
continue
}
rest = a
break
}
switch a {
case "--complete-vms":
for _, name := range cachedNames(rest) {
P(name)
}
case "--complete-vcenters":
for _, name := range cachedVCenters() {
P(name)
}
}
return true
}
return false
}
// ------------------------------------------------------------------ the cache
// inventoryPath is where the names are kept: the cache directory, beside the
// update note, and never in the configuration — losing it costs one Tab that
// offers nothing.
func inventoryPath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
}
// cacheEntry is one machine as the cache remembers it.
type cacheEntry struct {
vc string
when time.Time
name string
}
// saveInventory writes the machines of the servers that answered.
//
// The servers that did not are left exactly as they were: a vCenter that is
// down, or that this run was not asked about (`-v v308`), must not lose its
// machines out of the cache — the point of completion is to work when things
// are not working. Best effort throughout: a cache that cannot be written is
// not worth a word on the screen, let alone an error.
func saveInventory(answered []string, rows []vmRow) {
path, err := inventoryPath()
if err != nil {
return
}
fresh := map[string]bool{}
for _, name := range answered {
fresh[name] = true
}
kept := make([]cacheEntry, 0, len(rows))
for _, e := range loadInventory() {
if !fresh[e.vc] {
kept = append(kept, e)
}
}
now := time.Now()
for _, r := range rows {
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
}
var sb strings.Builder
for _, e := range kept {
// One line per machine: the server, when it was read, and the name.
// Tab separated because a machine name may hold a space and never a tab.
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
tmp := path + ".new"
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
return
}
if os.Rename(tmp, path) != nil {
os.Remove(tmp)
}
}
// loadInventory reads it back. A line that does not parse is dropped rather
// than reported: this file is a convenience and a broken one means one Tab
// without an answer.
func loadInventory() []cacheEntry {
path, err := inventoryPath()
if err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var out []cacheEntry
for _, line := range strings.Split(string(data), "\n") {
f := strings.Split(line, "\t")
if len(f) != 3 || f[0] == "" || f[2] == "" {
continue
}
when, err := time.Parse(time.RFC3339, f[1])
if err != nil {
continue
}
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
}
return out
}
// cachedNames are the machine names that begin with the prefix, once each and
// in order. Once each because the same name on two vCenters is one thing to
// type; in order because a completion list that moves about is a completion
// list nobody reads.
func cachedNames(prefix string) []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
continue
}
seen[e.name] = true
out = append(out, e.name)
}
sort.Strings(out)
return out
}
// cachedVCenters are the servers the cache has seen, which is what `-v`
// completes against. It comes out of the cache and not out of ~/.gvmrc on
// purpose: reading the configuration would seal a password standing in the
// clear in it, and a Tab key must not rewrite a file.
func cachedVCenters() []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.vc] {
continue
}
seen[e.vc] = true
out = append(out, e.vc)
}
sort.Strings(out)
return out
}
// inventoryAge is what `gvm config` says about the cache: how many machines it
// holds and how long ago each server was read.
func inventoryAge() string {
entries := loadInventory()
if len(entries) == 0 {
return "-"
}
newest := map[string]time.Time{}
var order []string
for _, e := range entries {
if _, seen := newest[e.vc]; !seen {
order = append(order, e.vc)
}
if e.when.After(newest[e.vc]) {
newest[e.vc] = e.when
}
}
sort.Strings(order)
parts := make([]string, 0, len(order))
for _, vc := range order {
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
}
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
}
// ----------------------------------------------------------------- the scripts
// vmFlags are the options that take a machine name and vcFlags the ones that
// take a vCenter — the only thing about gvm's own command line that the
// completion has to be told, because it is the only thing flaggy's generated
// script cannot know: it knows every option there is, and nothing about what
// any of them means.
//
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
// an option renamed there cannot leave a completion quietly offering the wrong
// thing.
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
// installedFunction is the completion function flaggy's own script installs,
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
// "complete -F _gvm_complete gvm" in bash.
//
// Taken from the script rather than written down here, because the name is
// flaggy's to choose: it builds it out of the parser's name, and a version that
// built it differently would leave the addendum below calling a function that
// does not exist, which in a shell is a completion that silently offers
// nothing.
func installedFunction(script string) string {
for _, line := range strings.Split(script, "\n") {
f := strings.Fields(line)
switch {
case len(f) >= 2 && f[0] == "compdef":
return f[1]
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
return f[2]
}
}
return ""
}
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
// offers and lists in the help, answered here instead so that the script can
// carry the machine names as well. A shell this does not know is left to
// flaggy, whose own message names the ones it can write.
func completionRequest(args []string) (shell string, ok bool) {
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
return "", false
}
return strings.ToLower(args[1]), true
}
// completionScript is flaggy's script for that shell with the names put on top:
//
// eval "$(gvm completion zsh)"
// gvm completion bash > /etc/bash_completion.d/gvm
//
// flaggy generates the half that is about gvm's own command line, from the
// parser itself, so no list here can fall behind the options that exist. This
// adds the half that is about the estate: after an option that takes a machine,
// the machines; after -v, the servers. Both go through --complete-vms, which
// reads the cache and never a vCenter.
func completionScript(shell, flaggyScript string) (string, bool) {
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
if !ok {
return "", false
}
// Without a function of flaggy's to fall back to there is nothing to add
// to: half a completion — machine names and no options — would be worse
// than the whole of flaggy's, which is what this then leaves in place.
delegate := installedFunction(flaggyScript)
if delegate == "" {
return flaggyScript, true
}
return flaggyScript + names(delegate), true
}
func zshNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
" " + strings.Join(vcFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
" esac",
" " + delegate + " \"$@\"",
"}",
"compdef _gvm_names gvm",
"",
}, "\n")
}
func bashNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
" " + strings.Join(vcFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
" esac",
" " + delegate,
"}",
"complete -F _gvm_names gvm",
"",
}, "\n")
}
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
// it stands — every subcommand and every option, without a list here to fall
// behind them. Empty for a shell flaggy does not write, which is the caller's
// signal to let flaggy answer for itself.
func flaggyCompletion(shell string) string {
switch shell {
case "zsh":
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
case "bash":
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
}
return ""
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/integrii/flaggy"
)
// cacheHome points the cache directory at a temporary one, so a test never
// reads or writes the cache of the person running it. os.UserCacheDir goes by
// HOME on macOS and by XDG_CACHE_HOME on Linux, so both are set.
func cacheHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache"))
return dir
}
func cacheRows(vc string, names ...string) []vmRow {
var rows []vmRow
for _, n := range names {
rows = append(rows, vmRow{vc: VCenter{Name: vc}, name: n})
}
return rows
}
func TestInventoryCacheRoundTrip(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
if got := cachedNames(""); !slices.Equal(got, []string{"db01", "web01"}) {
t.Errorf("the cache gave back %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v308"}) {
t.Errorf("the servers came back as %v", got)
}
if got := cachedNames("web"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix web matched %v", got)
}
// A shell completes what has been typed so far, whichever case it is in.
if got := cachedNames("WEB"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix WEB matched %v", got)
}
if got := cachedNames("nothing-like-this"); len(got) != 0 {
t.Errorf("a prefix that matches nothing gave %v", got)
}
}
// A sweep of one server must not lose the others' machines. The point of
// completing out of a cache is that it works when a vCenter is down — or when
// the last command was `gvm -v v308 vm -l`.
func TestASweepOfOneServerKeepsTheOthers(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308", "v108"},
append(cacheRows("v308", "web01"), cacheRows("v108", "old01")...))
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "web02"))
if got := cachedNames(""); !slices.Equal(got, []string{"old01", "web01", "web02"}) {
t.Errorf("after a sweep of one server the cache holds %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v108", "v308"}) {
t.Errorf("the servers came back as %v", got)
}
}
// A machine that has gone is gone from the cache of the server it was on.
func TestTheCacheForgetsWhatTheServerHasForgotten(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "temp01"))
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
if got := cachedNames(""); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the cache still holds %v", got)
}
}
// A cache that cannot be read is one Tab without an answer, never an error.
func TestABrokenCacheIsSilent(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, err := inventoryPath()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("nonsense\nv308\tnot-a-date\tweb01\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := cachedNames(""); len(got) != 0 {
t.Errorf("a broken cache offered %v", got)
}
if got := inventoryAge(); got != "-" {
t.Errorf("a broken cache is described as %q", got)
}
}
// The file holds machine names read off a vCenter, and the cache directory is
// not private, so it is written the way the configuration is.
func TestTheCacheIsNotWorldReadable(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, _ := inventoryPath()
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("the cache is mode %v", st.Mode().Perm())
}
}
// `gvm config` says how old the cache is, because a completion offering a
// machine deleted last month should be explicable.
func TestConfigSaysHowOldTheCacheIs(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
got := inventoryAge()
if !strings.Contains(got, "2 machines") || !strings.Contains(got, "v308") {
t.Errorf("inventoryAge = %q", got)
}
if !strings.Contains(got, "ago") {
t.Errorf("inventoryAge does not say when: %q", got)
}
}
// The scripts complete against the cache and never against a vCenter: a Tab key
// that logs in three times is a Tab key nobody presses twice.
func TestTheCompletionScriptsAskGvmAndNothingElse(t *testing.T) {
for _, shell := range []string{"zsh", "bash"} {
script, ok := completionScript(shell, "# flaggy's half\n_half() {\n}\ncompdef _half gvm\n")
if !ok {
t.Fatalf("no script for %s", shell)
}
if !strings.Contains(script, "--complete-vms") || !strings.Contains(script, "--complete-vcenters") {
t.Errorf("the %s script does not ask gvm for the names:\n%s", shell, script)
}
// flaggy's half is carried, not replaced: that is the half that knows
// every subcommand and every option.
if !strings.Contains(script, "# flaggy's half") {
t.Errorf("the %s script threw flaggy's own half away", shell)
}
if !strings.Contains(script, "_gvm_names") {
t.Errorf("the %s script does not install itself", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
}
// A shell neither half knows is left to flaggy, whose own message names the
// ones it can write.
if _, ok := completionScript("klingon", ""); ok {
t.Error("a script was written for a shell nobody has")
}
}
// The addendum falls back to the function flaggy's own script installs, whose
// name is read off the script rather than written down twice. If flaggy ever
// names it differently the wrapper follows it there.
func TestTheAddendumDelegatesToFlaggysOwnFunction(t *testing.T) {
for _, c := range []struct{ script, want string }{
{"_gvm() {\n}\ncompdef _gvm gvm\n", "_gvm"},
{"_gvm_complete() {\n}\ncomplete -F _gvm_complete gvm\n", "_gvm_complete"},
{"_thing() {\n}\ncompdef _some_other_name thing\n", "_some_other_name"},
{"nothing installs anything here\n", ""},
} {
if got := installedFunction(c.script); got != c.want {
t.Errorf("installedFunction found %q, want %q", got, c.want)
}
}
// And on the real thing: flaggy builds the name out of the parser's name,
// which is the only part of the parser this depends on.
flaggy.SetName("gvm")
for _, shell := range []string{"zsh", "bash"} {
generated := flaggyCompletion(shell)
delegate := installedFunction(generated)
if delegate == "" {
t.Fatalf("nothing could be found to delegate to in flaggy's %s script:\n%s", shell, generated)
}
if !strings.Contains(generated, delegate+"()") {
t.Errorf("flaggy's %s script installs %s without defining it", shell, delegate)
}
script, _ := completionScript(shell, generated)
if !strings.Contains(script, delegate) {
t.Errorf("the %s addendum does not fall back to %s", shell, delegate)
}
}
}
// A shell script that is not valid shell is worse than none: the shell says so
// on every Tab. Both are checked with the shell's own parser, where there is one.
func TestTheCompletionScriptsAreValidShell(t *testing.T) {
flaggy.SetName("gvm")
for _, c := range []struct{ shell, flag string }{{"zsh", "-n"}, {"bash", "-n"}} {
if _, err := exec.LookPath(c.shell); err != nil {
t.Logf("no %s here to check with", c.shell)
continue
}
script, ok := completionScript(c.shell, flaggyCompletion(c.shell))
if !ok {
t.Fatalf("no %s script", c.shell)
}
path := filepath.Join(t.TempDir(), "completion."+c.shell)
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(c.shell, c.flag, path).CombinedOutput()
if err != nil {
t.Errorf("the %s script does not parse: %v\n%s\n%s", c.shell, err, out, script)
}
}
}
// `gvm completion <shell>` is flaggy's own subcommand, answered a step earlier.
// It has to be recognised exactly as flaggy would recognise it, or the two
// disagree about what the command line said.
func TestCompletionRequest(t *testing.T) {
for _, c := range []struct {
args []string
shell string
ok bool
}{
{[]string{"completion", "zsh"}, "zsh", true},
{[]string{"completion", "BASH"}, "bash", true},
{[]string{"Completion", "zsh"}, "zsh", true},
{[]string{"completion"}, "", false}, // flaggy asks which shell
{[]string{"vm", "-l"}, "", false},
{nil, "", false},
} {
shell, ok := completionRequest(c.args)
if ok != c.ok || shell != c.shell {
t.Errorf("completionRequest(%v) = %q, %v; want %q, %v", c.args, shell, ok, c.shell, c.ok)
}
}
}
// Every option the names are offered after is an option gvm actually has. The
// two lists cannot be one — a shell script has to name them as strings — so
// this is what keeps them from drifting apart. The subcommands and the rest of
// the options need no such check: flaggy writes those out of the parser itself.
func TestCompletionOptionsAreReal(t *testing.T) {
src, err := os.ReadFile("gvm.go")
if err != nil {
t.Fatal(err)
}
text := string(src)
for _, f := range append(append([]string{}, vmFlags...), vcFlags...) {
if !strings.Contains(text, `"`+strings.TrimLeft(f, "-")+`"`) {
t.Errorf("the completion offers %s, which gvm.go does not declare", f)
}
}
}
// A prefix beginning with a dash is still a prefix: the scripts put "--" in
// front of it so it cannot be taken for an option of gvm's own.
func TestACompletionPrefixMayLookLikeAnOption(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "-odd-name", "web01"))
if got := cachedNames("-odd"); !slices.Equal(got, []string{"-odd-name"}) {
t.Errorf("the prefix -odd matched %v", got)
}
}
+234 -8
View File
@@ -30,6 +30,7 @@ type Config struct {
SMTPHost string // relay to hand it to
SMTPPort string // its port (default 25)
Telemetry string // URL `host -t` posts to; unset turns the posting off
SSH string // the command the sheet's `h` runs; %h is the machine
}
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
@@ -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 sheet's 'h' logs in to a guest ---\n")
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
b.WriteString("# ssh = ssh -l someone %h\n")
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
PE("could not create "+path, err.Error())
@@ -477,3 +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
View File
@@ -0,0 +1,192 @@
// datastore.go — what the datastores are doing.
//
// The gap next to `gvm host`. A cluster is watched by its processor load and its
// memory, and then it falls over because a datastore filled up — which nothing
// in gvm could show, and which is the one figure a snapshot report (snapold.go)
// makes you want to look at next.
//
// Same shape as hoststat: one line per datastore, the numbers in the palette's
// roles, and -t posts the same figures to the monitoring server.
package main
import (
"sort"
"strings"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var dsColumns = []printColumn{
{header: "DATASTORE", width: 20},
{header: "TYPE", width: 5},
{header: "CAPACITY", width: 9, right: true},
{header: "FREE", width: 9, right: true},
{header: "USED%", width: 6, right: true},
{header: "PROVISIONED", width: 12, right: true},
{header: "OVER%", width: 6, right: true},
{header: "VM", width: 4, right: true},
{header: "STATUS", width: 7},
{header: "STATE", width: 14},
}
// dsstat prints one line per datastore: how big it is, what is left, what has
// been promised out of it, and how many machines live on it.
//
// Every figure comes out of summary, and vSphere only guarantees those while
// the datastore is accessible. An unreachable datastore therefore reports
// dashes rather than zeroes — a datastore that says 0 B free looks like an
// emergency, and a datastore nobody can reach is a different one.
func dsstat(vc VCenter, telemetry string) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
stores, err := s.datastores("name", "summary", "overallStatus", "vm")
if err != nil {
return err
}
sort.Slice(stores, func(a, b int) bool {
return strings.ToLower(dsName(stores[a])) < strings.ToLower(dsName(stores[b]))
})
var capacity, free int64
printRow(dsColumns, "", nil)
for _, ds := range stores {
sum := ds.Summary
used, usedKnown := dsUsedPercent(sum)
over, overKnown := dsOverPercent(sum)
if sum.Accessible {
capacity += sum.Capacity
free += sum.FreeSpace
}
printRow(dsColumns, "", []cell{
{dsName(ds), cWhite.fg()},
{sum.Type, colAside},
dsSize(sum.Capacity, sum.Accessible),
dsSize(sum.FreeSpace, sum.Accessible),
pctCell(used, usedKnown, loadColor(used, usedKnown)),
dsSize(dsProvisioned(sum), sum.Accessible),
pctCell(over, overKnown, overColor(over, overKnown)),
{Itoa(len(ds.Vm)), colSize},
{string(ds.OverallStatus), statusColor(ds.OverallStatus)},
{dsState(sum), dsStateColor(sum)},
})
if telemetry != "" {
post(telemetry, SF("ds,%s,%d,%d,%.2f,%d,%d,%s",
dsName(ds), sum.Capacity, sum.FreeSpace, used,
dsProvisioned(sum), len(ds.Vm), ds.OverallStatus))
}
}
// One line of estate: the figure somebody asks for immediately after
// reading the table, and the reason the table is worth printing at all.
if capacity > 0 {
P()
PF("%s %s of %s free (%s used)\n",
Cwb(plural(len(stores), "datastore")),
Co(units.ByteSize(free).String()),
Co(units.ByteSize(capacity).String()),
Co(SF("%.0f %%", 100.0-100.0/float64(capacity)*float64(free))))
}
return nil
}
// dsName prefers the summary's name over the entity's: they are two different
// vSphere properties and a datastore that was renamed can answer differently to
// each, the same way a host can (see countOn in host.go).
func dsName(ds mo.Datastore) string {
if ds.Summary.Name != "" {
return ds.Summary.Name
}
return ds.Name
}
// dsProvisioned is what has been promised out of the datastore: what is in use
// plus what thin disks are entitled to grow into. Past the capacity that is a
// promise the datastore cannot keep if every machine takes what it was offered,
// which is why it has a column of its own rather than being folded into "used".
func dsProvisioned(sum types.DatastoreSummary) int64 {
return sum.Capacity - sum.FreeSpace + sum.Uncommitted
}
func dsUsedPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 - 100.0/float64(sum.Capacity)*float64(sum.FreeSpace), true
}
func dsOverPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 / float64(sum.Capacity) * float64(dsProvisioned(sum)), true
}
// overColor: thin provisioning past the capacity is ordinary and not a fault,
// so a hundred per cent is a word of warning rather than an alarm; half again
// as much as there is, is an alarm.
func overColor(pct float64, known bool) string {
switch {
case !known:
return colOff
case pct >= 150:
return colFull
case pct >= 100:
return colBusy
}
return colSize
}
// dsSize is a byte figure, or a dash where the datastore cannot vouch for it.
func dsSize(b int64, accessible bool) cell {
if !accessible {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// pctCell is a percentage in a narrow column: no sign, because the header has
// one, and a dash where there is no figure rather than a nought.
func pctCell(pct float64, known bool, col string) cell {
if !known {
return cell{"-", colOff}
}
return cell{SF("%.0f", pct), col}
}
// dsState is what vSphere says about the datastore itself, as opposed to the
// alarms rolled up in its status: whether it can be reached at all, and whether
// it is being emptied for removal.
func dsState(sum types.DatastoreSummary) string {
var parts []string
if !sum.Accessible {
parts = append(parts, "inaccessible")
}
switch sum.MaintenanceMode {
case "", string(types.DatastoreSummaryMaintenanceModeStateNormal):
default:
parts = append(parts, string(sum.MaintenanceMode))
}
if len(parts) == 0 {
return "ok"
}
return strings.Join(parts, " ")
}
func dsStateColor(sum types.DatastoreSummary) string {
if !sum.Accessible {
return colFull
}
if sum.MaintenanceMode != "" &&
sum.MaintenanceMode != string(types.DatastoreSummaryMaintenanceModeStateNormal) {
return colBusy
}
return colOK
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
func dsSummary(capacity, free, uncommitted int64, accessible bool) types.DatastoreSummary {
return types.DatastoreSummary{
Name: "LocalDS_0", Type: "VMFS", Capacity: capacity,
FreeSpace: free, Uncommitted: uncommitted, Accessible: accessible,
}
}
// What has been promised out of a datastore is what is in use plus what thin
// disks may still grow into — the figure that says whether the datastore can
// keep its promises, and the reason it is a column of its own.
func TestProvisionedAndUsed(t *testing.T) {
sum := dsSummary(1000, 400, 800, true)
if got := dsProvisioned(sum); got != 1400 {
t.Errorf("provisioned = %d, want 1400", got)
}
used, ok := dsUsedPercent(sum)
if !ok || used != 60 {
t.Errorf("used = %v (known %v), want 60", used, ok)
}
over, ok := dsOverPercent(sum)
if !ok || over != 140 {
t.Errorf("over = %v (known %v), want 140", over, ok)
}
}
// A datastore nobody can reach cannot vouch for its own figures, so it reports
// none. A datastore that says 0 B free looks like an emergency; one that cannot
// be reached is a different one.
func TestAnUnreachableDatastoreReportsNothing(t *testing.T) {
sum := dsSummary(1000, 0, 0, false)
if _, ok := dsUsedPercent(sum); ok {
t.Error("an inaccessible datastore reported a usage figure")
}
if _, ok := dsOverPercent(sum); ok {
t.Error("an inaccessible datastore reported an over-commitment figure")
}
if got := dsSize(1000, false); got.text != "-" || got.col != colOff {
t.Errorf("its capacity is shown as %q", got.text)
}
if dsState(sum) != "inaccessible" || dsStateColor(sum) != colFull {
t.Errorf("its state is %q", dsState(sum))
}
}
// Thin provisioning past the capacity is ordinary practice, not a fault: a word
// of warning at a hundred per cent, an alarm at half again as much.
func TestOverCommitmentColours(t *testing.T) {
for _, c := range []struct {
pct float64
want string
}{{50, colSize}, {99, colSize}, {100, colBusy}, {149, colBusy}, {150, colFull}} {
if got := overColor(c.pct, true); got != c.want {
t.Errorf("%.0f %% over-committed is coloured wrongly", c.pct)
}
}
if overColor(0, false) != colOff {
t.Error("an unknown over-commitment is coloured as a figure")
}
}
func TestPercentCellHasNoSignAndNoNought(t *testing.T) {
if got := pctCell(93.4, true, colSize); got.text != "93" {
t.Errorf("a percentage is shown as %q", got.text)
}
if got := pctCell(0, false, colSize); got.text != "-" {
t.Errorf("an unknown percentage is shown as %q", got.text)
}
}
// A datastore in maintenance is being emptied on purpose: worth saying, not
// worth an alarm.
func TestMaintenanceModeIsSaidButNotAlarmed(t *testing.T) {
sum := dsSummary(1000, 500, 0, true)
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateEnteringMaintenance)
if got := dsState(sum); got != "enteringMaintenance" {
t.Errorf("the state reads %q", got)
}
if dsStateColor(sum) != colBusy {
t.Error("entering maintenance is painted as a fault")
}
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateNormal)
if got := dsState(sum); got != "ok" {
t.Errorf("an ordinary datastore reads %q", got)
}
if dsStateColor(sum) != colOK {
t.Error("an ordinary datastore is not painted as ordinary")
}
}
// The name in the summary and the name of the entity are two different vSphere
// properties, and a renamed datastore can answer differently to each — the same
// trap the host counts fell into.
func TestDatastoreNamePrefersTheSummary(t *testing.T) {
ds := mo.Datastore{Summary: types.DatastoreSummary{Name: "new-name"}}
ds.Name = "old-name"
if got := dsName(ds); got != "new-name" {
t.Errorf("dsName = %q", got)
}
ds.Summary.Name = ""
if got := dsName(ds); got != "old-name" {
t.Errorf("with no summary name, dsName = %q", got)
}
}
+102
View File
@@ -0,0 +1,102 @@
// events.go — one machine's recent history.
//
// `gvm log` is the whole vCenter over the last hour, which is the right shape
// for a mail and the wrong one for the question actually being asked in front
// of a machine's sheet: why is this thing off, who rebooted it, what happened
// at four this morning. vCenter keeps the answer per object, so this asks it
// per object.
//
// It is not part of the sheet's own reading. Opening a machine costs one call
// for its snapshots and nothing else, and it stays that way: the events are
// fetched when they are asked for (the action menu's 'e'), for the one machine
// on screen.
package main
import (
"context"
"strings"
"time"
"github.com/vmware/govmomi/event"
"github.com/vmware/govmomi/vim25/types"
)
// How many events are worth having, and how long to wait for them. The page is
// short on purpose: this answers "what just happened to this machine", and
// anything older than the last couple of dozen lines is a question for `log`.
const (
eventPage = 25
eventWait = 20 * time.Second
)
// eventLine is one event as the sheet shows it.
type eventLine struct {
text string
col string
}
// eventsOf reads the machine's most recent events, oldest first — the order a
// history reads in, so the last line is the latest thing that happened.
func eventsOf(r vmRow) ([]eventLine, error) {
if r.sess == nil {
return nil, errf("no connection to %s", r.vc.Name)
}
// Bounded. The underlying collector waits for vCenter to hand over its
// first page, and this runs in the interactive loop: a server that accepts
// the request and then says nothing would otherwise freeze the screen
// mid-draw with no key being read.
ctx, cancel := context.WithTimeout(r.sess.ctx, eventWait)
defer cancel()
var found []types.BaseEvent
err := event.NewManager(r.sess.client.Client).Events(ctx,
[]types.ManagedObjectReference{r.ref}, eventPage, false, false,
func(_ types.ManagedObjectReference, evs []types.BaseEvent) error {
found = append(found, evs...)
return nil
})
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, errf("%s did not answer within %s", r.vc.Name, eventWait)
}
return nil, errf("%s: cannot read the events of %s: %w", r.vc.Name, r.name, err)
}
out := make([]eventLine, 0, len(found))
for _, e := range found {
out = append(out, eventLineOf(e))
}
return out, nil
}
// eventLineOf is one event: when, and what. The severity decides the colour and
// is otherwise left out — "info" down twenty lines is twenty times four
// characters spent saying nothing.
func eventLineOf(e types.BaseEvent) eventLine {
base := e.GetEvent()
msg := strings.TrimSpace(base.FullFormattedMessage)
if msg == "" {
msg = SF("%T", e)
}
// One line per event: a formatted vSphere message can carry newlines, and a
// value with a newline in it would break the sheet's own line counting.
msg = strings.Join(strings.Fields(msg), " ")
return eventLine{
text: base.CreatedTime.Local().Format("02.01. 15:04") + " " + msg,
col: eventColor(severity(e)),
}
}
// eventColor takes log.go's reading of the severity into the palette. Only the
// two that matter are coloured; the rest is history, and history is grey.
func eventColor(sev string) string {
switch strings.ToLower(sev) {
case "error":
return colFull
case "warning":
return colBusy
}
return colAside
}
+280
View File
@@ -0,0 +1,280 @@
// guest.go — the three ways out of a machine's sheet.
//
// Everything else in gvm looks at machines. These look them up: log in to one,
// take its address away with you, open it in the vSphere client. They are the
// keystrokes that stop gvm being a viewer you then have to type an address out
// of by hand — and none of them touches the vCenter at all, which is why they
// are letters of the sheet itself rather than entries in the action menu.
package main
import (
"encoding/base64"
"errors"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// sshTarget is what to connect to: the name the guest calls itself, or its
// address. The name is preferred where there is one — it is what is in the known
// hosts file, and an address that came out of VMware Tools may be one of several.
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
// sshTargetIs is the same, plus which of the two it turned out to be. What was
// copied is worth naming — a sheet shows a hostname and an address, and
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
// working that out a second time somewhere else is how two answers drift apart.
func (r vmRow) sshTargetIs() (target, kind string) {
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
return strings.TrimSpace(g.HostName), "hostname"
}
if ip := r.ip(); ip != "-" {
return ip, "address"
}
return "", ""
}
// defaultSSH is what `h` runs when the configuration says nothing.
const defaultSSH = "ssh root@%h"
// sshCommand is the command line to run, as argv: the configured template with
// the target put where %h stands, or appended when it does not stand anywhere.
//
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
// anything else is a second step once the session is up — and having it in the
// default means the common case needs no configuration file at all. A template
// of one's own overrides it entirely, root and all.
//
// The target is its own argument and never goes through a shell. It comes from
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
// and a guest that called itself `; rm -rf ~` would otherwise be running that
// on the operator's workstation. The template is the operator's own line out of
// their own configuration file, so it is split on spaces and no further:
// quoting is not supported, which is a limit worth having here.
func sshCommand(template, target string) []string {
if strings.TrimSpace(template) == "" {
template = defaultSSH
}
fields := strings.Fields(template)
argv := make([]string, 0, len(fields)+1)
placed := false
for _, f := range fields {
if strings.Contains(f, "%h") {
argv = append(argv, strings.ReplaceAll(f, "%h", target))
placed = true
continue
}
argv = append(argv, f)
}
if !placed {
argv = append(argv, target)
}
return argv
}
// vsphereURL is the machine's page in the vSphere client.
//
// The shape is the H5 client's own: the object's reference and the vCenter's
// instance UUID, which is the serverGuid that client puts in every link. The
// UUID cannot be worked out from the configuration — it is asked of the server
// on connecting (session.instanceUUID) — so a machine read over a connection
// that has gone has no URL rather than a wrong one.
func vsphereURL(r vmRow) string {
if r.sess == nil {
return ""
}
return vsphereLink(r.vc.URL, r.ref.Value, r.sess.instanceUUID())
}
// vsphereLink is the link itself, from the three things it is made of — so the
// shape can be checked without a server, which is the only way it can be
// checked at all: a wrong link opens a client that says "object not found",
// which looks like a vCenter problem rather than a gvm one.
func vsphereLink(vcURL, moref, guid string) string {
if vcURL == "" || moref == "" || guid == "" {
return ""
}
return SF("%s/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:%s:%s/summary",
strings.TrimSuffix(vcURL, "/"), moref, guid)
}
// openerCommand is how this operating system opens a URL. Nothing is opened
// where there is no answer rather than something being guessed at, and the
// caller says the URL out loud instead — which is the useful half anyway.
func openerCommand() string {
switch runtime.GOOS {
case "darwin":
return "open"
case "linux":
return "xdg-open"
}
return ""
}
// openURL hands the URL to the desktop and does not wait for it. A browser
// takes seconds to start and prints its own complaints; neither belongs in a
// full-screen list.
func openURL(url string) error {
opener := openerCommand()
if opener == "" {
return errf("no way to open a browser on %s", runtime.GOOS)
}
cmd := exec.Command(opener, url)
cmd.Stdout, cmd.Stderr = nil, nil
if err := cmd.Start(); err != nil {
return errf("cannot run %s: %w", opener, err)
}
go cmd.Wait() // reaped in the background; nothing here waits on a browser
return nil
}
// osc52 is the escape sequence that puts text in the clipboard of the terminal
// that is being looked at, wherever that terminal is running. It is the only way
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
// end of a login copies into the clipboard of a machine nobody is sitting at.
//
// It is also the way a terminal is free to ignore, and several do until they are
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
// is why it is not the only thing tried. See toClipboard.
func osc52(text string) string {
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}
// toClipboard puts text where the next paste will find it and reports the way it
// got there, named — "pbcopy" — or empty when the escape sequence was the only
// thing on offer. The caller says so on the status line: a copy nobody can see
// happen is one that has to be described, or the only way to find out whether it
// worked is to paste somewhere and look.
//
// Both routes are used, because either alone leaves somebody with nothing: the
// local command always works where there is one, and the sequence is what
// carries the text home from the far end of an ssh login.
func (b *browser) toClipboard(text string) string {
b.write(osc52(text))
argv := clipTool()
if argv == nil {
return ""
}
if err := runClipTool(argv, text); err != nil {
return ""
}
return filepath.Base(argv[0])
}
// clipTool is the command that puts something in this machine's clipboard, where
// this is the machine the person is sitting at. Over an ssh login it is not:
// there the terminal's own sequence is the only route that ends up where the
// person can paste it, and a local clipboard would be the wrong machine's.
func clipTool() []string {
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
return nil
}
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
if runtime.GOOS == "darwin" {
candidates = [][]string{{"pbcopy"}}
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
// A Linux console or a machine with no session to speak of: there is
// nothing for xclip to hand the text to, and it would sit there waiting.
return nil
}
for _, c := range candidates {
if path, err := exec.LookPath(c[0]); err == nil {
return append([]string{path}, c[1:]...)
}
}
return nil
}
// runClipTool feeds the text to it on standard input, which is how all of them
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
// hostname into a pasted hostname and a return.
func runClipTool(argv []string, text string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
// runInTerminal gives the terminal back, runs a command in it, and takes it
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
// and the keyboard.
//
// The keystroke reader is rebuilt on the way back in: the one that was running
// is reading a file descriptor that closing the terminal has taken away from
// it, and its goroutine ends when that read fails.
func (b *browser) runInTerminal(argv []string) error {
if len(argv) == 0 {
return errf("nothing to run")
}
b.close() // clears the screen, puts the cursor back, hands the tty back
release := holdTerminalSignals()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
err := cmd.Run()
release()
if err != nil && !interrupted(err) {
// Something to read: the message would be wiped by the next frame, so
// the screen is held until somebody has seen it.
PF("\n%s %v\n", Crb(argv[0]+":"), err)
PF("%s", Cd("press enter to come back to gvm "))
os.Stdin.Read(make([]byte, 1))
}
// Coming back in. If the terminal cannot be taken again there is nothing
// left to draw on, so it is said here, in the ordinary terminal that is
// still on screen — and the loop ends on its own: the reader that was
// running is reading a closed descriptor and reports that as a Ctrl-C.
if oerr := b.open(); oerr != nil {
PE("cannot take the terminal back", oerr.Error())
return oerr
}
return err
}
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
// the list one was going back to was not.
//
// They are caught rather than ignored, and the difference matters: exec resets a
// caught signal to its default in the child, while an ignored one is inherited.
// An ssh that cannot be interrupted while it hangs on a machine that is not
// answering would be worse than what this fixes.
//
// The returned func puts them back the way they were, which is gvm's own raw
// mode reading Ctrl-C as a key like any other.
func holdTerminalSignals() func() {
// Buffered and never read: the signal package sends without blocking and
// drops what does not fit, which is the whole intent — these are being
// swallowed, not handled.
ch := make(chan os.Signal, 4)
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
return func() { signal.Stop(ch) }
}
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
// Ctrl-\) rather than because something went wrong. Nothing is held on the
// screen for it: the person who pressed it knows what happened and wants to be
// back in the list, not reading that ssh got a signal.
func interrupted(err error) bool {
var exit *exec.ExitError
if !errors.As(err, &exit) {
return false
}
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
}
// A shell between gvm and the signal reports it as its own exit status
// instead, in the shells' 128+signal spelling.
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The name the guest calls itself is what is in the known hosts file; the
// address is the fallback, and a machine whose guest says nothing has neither.
func TestSSHTarget(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.sshTarget(); got != "web01.example" {
t.Errorf("sshTarget = %q, want the hostname", got)
}
r.vm.Guest.HostName = ""
if got := r.sshTarget(); got != "10.0.0.5" {
t.Errorf("with no hostname, sshTarget = %q", got)
}
r.vm.Guest = nil
if got := r.sshTarget(); got != "" {
t.Errorf("with no guest information at all, sshTarget = %q", got)
}
}
func TestSSHCommand(t *testing.T) {
for _, c := range []struct {
template string
want []string
}{
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
{"mosh %h", []string{"mosh", "web01"}},
{"ssh root@%h", []string{"ssh", "root@web01"}},
} {
if got := sshCommand(c.template, "web01"); !slices.Equal(got, c.want) {
t.Errorf("sshCommand(%q) = %v, want %v", c.template, got, c.want)
}
}
}
// A clipboard is invisible, so what the status line says about it has to be
// exact: which of the two the machine gave up — the hostname or the address —
// and which clipboard it went into.
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
// Pretending to be an ssh login does two things: it is the case where the
// escape sequence is the only route, and it keeps the tests off the
// clipboard of whoever is running them.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
for _, c := range []struct {
what string
row func() vmRow
want []string
}{
{"a guest that reports its name", func() vmRow {
return testRow("web01", true, "10.0.0.5")
}, []string{"hostname", "web01.example"}},
{"a guest that reports only an address", func() vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.HostName = ""
return r
}, []string{"address", "10.0.0.5"}},
} {
r := c.row()
b := &browser{rows: []vmRow{r}, view: []int{0}}
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = pw
b.copyAddress(r)
pw.Close()
sent, _ := io.ReadAll(pr)
pr.Close()
for _, want := range c.want {
if !strings.Contains(b.status, want) {
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
}
}
// And the sequence carried the same string, base64 and all.
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
if !strings.Contains(string(sent), payload) {
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
}
}
}
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
// far end of a login copies into the clipboard of a machine nobody is sitting
// at, and the terminal's own sequence is the only route home.
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
t.Setenv("SSH_CONNECTION", "")
t.Setenv("SSH_TTY", "/dev/ttys004")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
}
// Whatever the tool is, it takes the text on standard input and gets it verbatim
// — no trailing newline, which in a clipboard turns a pasted hostname into a
// pasted hostname and a return.
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
out := filepath.Join(t.TempDir(), "clipboard")
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
t.Fatalf("runClipTool: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(got) != "web01.example" {
t.Errorf("the clipboard would get %q", got)
}
}
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
// reads but a SIGINT to the whole foreground process group, and gvm is in that
// group. While a child has the screen the signal has to be caught and dropped:
// were it not, this test would kill the test binary rather than fail.
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
release := holdTerminalSignals()
defer release()
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("cannot send myself a %v: %v", sig, err)
}
}
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
time.Sleep(50 * time.Millisecond)
// And the child must still die of it, which is why the signal is caught and
// not ignored: exec resets a caught signal to its default in the child,
// while an ignored one is inherited — signal.Ignore here would leave an ssh
// that cannot be interrupted while it hangs on a machine that is not
// answering.
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
}
}
// And a child that died of that keystroke is told apart from one that failed, so
// the screen is not held with "signal: interrupt" over something somebody meant
// to do.
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
for _, c := range []struct {
script string
want bool
}{
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
{"exit 1", false}, // a remote command that failed
{"exit 255", false}, // ssh's own "could not connect"
{"exit 0", false}, // nothing wrong at all
} {
err := exec.Command("sh", "-c", c.script).Run()
if got := interrupted(err); got != c.want {
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
}
}
// Something that never got as far as a child at all is not an interruption.
if interrupted(errf("nothing to run")) {
t.Error("a plain error was taken for a Ctrl-C")
}
}
// The target is one argument and never a piece of a shell command. It comes
// from the guest — a name the guest chose for itself — so a machine that called
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
// as a command that runs.
func TestTheTargetIsNeverShellCode(t *testing.T) {
nasty := "; rm -rf ~"
got := sshCommand("ssh -l root %h", nasty)
if len(got) != 4 || got[3] != nasty {
t.Fatalf("the target was taken apart: %v", got)
}
for _, arg := range got[:3] {
if strings.Contains(arg, "rm") {
t.Errorf("the target leaked into %q", arg)
}
}
}
// The link is the H5 client's own shape: the object's reference and the
// vCenter's instance UUID, which is the serverGuid that client wants.
func TestVsphereLink(t *testing.T) {
got := vsphereLink("https://v308.example/", "vm-42", "6ff1a05e-1111")
want := "https://v308.example/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:vm-42:6ff1a05e-1111/summary"
if got != want {
t.Errorf("vsphereLink =\n %s\nwant\n %s", got, want)
}
// A missing piece gives no link rather than a wrong one: a link that opens
// a client saying "object not found" looks like a vCenter fault.
for _, c := range [][3]string{
{"", "vm-42", "guid"}, {"https://v308.example", "", "guid"}, {"https://v308.example", "vm-42", ""},
} {
if got := vsphereLink(c[0], c[1], c[2]); got != "" {
t.Errorf("vsphereLink(%q, %q, %q) = %q, want nothing", c[0], c[1], c[2], got)
}
}
}
// A machine that was read over a connection that has gone has no link.
func TestVsphereURLNeedsTheConnection(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := vsphereURL(r); got != "" {
t.Errorf("a row with no session produced %q", got)
}
}
// The clipboard is the terminal's, not the machine's: gvm is run over ssh as
// often as not, and pbcopy would then copy into a clipboard nobody is looking
// at. This is the escape sequence that asks the terminal itself.
func TestOsc52CarriesTheTextItself(t *testing.T) {
got := osc52("10.0.0.5")
if !strings.HasPrefix(got, "\x1b]52;c;") || !strings.HasSuffix(got, "\a") {
t.Fatalf("osc52 = %q", got)
}
payload := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]52;c;"), "\a")
back, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("the payload is not base64: %v", err)
}
if string(back) != "10.0.0.5" {
t.Errorf("the clipboard would get %q", back)
}
}
// The sheet says why a key cannot do anything rather than swallowing it, and the
// two that need somewhere to connect to say exactly that.
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest = nil
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
b := &browser{rows: []vmRow{r}, view: []int{0}}
for _, k := range []rune{'h', 'y'} {
b.setStatus("", "")
b.detailRune(k)
if !strings.Contains(b.status, "no address") {
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
}
}
// With an address there is nothing to object to. Only the check is asked
// here — what follows it is an ssh session and a clipboard.
b.rows[0] = testRow("web01", true, "10.0.0.5")
if !b.hasAddress(b.rows[0]) {
t.Errorf("a machine with an address was refused: %s", b.status)
}
}
// Every letter in the menu reaches exactly one entry, or one of them is
// unreachable — and the snapshot half must not have taken a letter the
// power half already uses.
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil) {
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
}
}
+102 -15
View File
@@ -37,6 +37,9 @@ var helpTail = strings.Join([]string{
" --update Fetch the newest release and replace this binary",
" --check-update Look for a newer release, change nothing",
"",
" Shell completion:",
" gvm completion zsh The completion script, machine names included",
"",
" Run 'gvm' with no subcommand to browse the machines interactively.",
}, "\n")
@@ -44,7 +47,7 @@ var helpTail = strings.Join([]string{
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.0.0"
var version = "1.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
@@ -82,14 +95,18 @@ func run() error {
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
var vmSort string
var vmReverse bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, power, cpu%, mem%, size, cpus, vc, host, ip")
var vmReverse, vmIssues, vmJSON bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
// The destructive options deliberately have no short letter: --revert and
// --removeall have to be spelled out, so neither can be reached by a slip of
// one key next to a harmless one.
var snapLs, snapNew_, snapRm, snapRmAll, snapRevertTo, snapName string
var snapOld, snapMail bool
snapDays := snapOldDays
subSnap := flaggy.NewSubcommand("snap")
subSnap.Description = "Snapshot commands"
subSnap.String(&snapLs, "l", "list", "List the snapshots of <vm>")
@@ -98,6 +115,12 @@ func run() error {
subSnap.String(&snapRm, "r", "remove", "Remove one snapshot of <vm>, named with -s")
subSnap.String(&snapRevertTo, "", "revert", "Revert <vm> to the snapshot named with -s (destroys everything since)")
subSnap.String(&snapRmAll, "", "removeall", "Remove all snapshots of <vm>")
// The age report reads every server at once, like `vm -l` and unlike the
// rest of this subcommand: housekeeping is a question about the estate, not
// about one vCenter. -v still narrows it.
subSnap.Bool(&snapOld, "", "old", "Report the snapshots older than -d days, on every vCenter")
subSnap.Int(&snapDays, "d", "days", "How old is old, for --old")
subSnap.Bool(&snapMail, "m", "mail", "Mail that report as well")
var pwOn, pwShutdown, pwReboot, pwOff, pwReset string
subPower := flaggy.NewSubcommand("power")
@@ -114,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")
@@ -122,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()
@@ -142,12 +189,26 @@ func run() error {
if err != nil {
return err
}
if vmList { // -l prints; anything else browses
return lsvm(targets, vmMatch, vmSort, vmReverse)
// --json and --issues are ways of printing, so they mean -l without
// having to be told twice: browsing a machine list as JSON is not a
// thing, and a full-screen list has ^w for the other one.
if vmList || vmJSON || vmIssues {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch)
return browseVMs(targets, vmMatch, cfg.SSH)
case subSnap.Used:
if snapOld {
targets, err := cfg.targets(vcname)
if err != nil {
return err
}
if snapDays < 0 {
return errf("-d wants a number of days, not %d", snapDays)
}
return snapOldReport(cfg, targets, snapDays, snapMail)
}
vc, err := cfg.pick(vcname)
if err != nil {
return err
@@ -212,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("")
@@ -235,6 +304,9 @@ func run() error {
return vmlog(cfg, vc, logMinutes, logMail)
case subConfig.Used:
if cfgPassword != "" {
return setPassword(cfg, cfgPassword)
}
return showConfig(cfg)
}
@@ -249,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
@@ -268,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)
@@ -280,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
View File
@@ -26,10 +26,26 @@ func TestHelpMentionsEveryUpdateOptionItAnswers(t *testing.T) {
}
}
// The completion subcommand is documented too — flaggy lists it as well,
// but not that the script it writes carries machine names. The two options
// the scripts themselves call are not documented, the same way the
// background refresh is not: nobody types --complete-vms.
if !strings.Contains(helpTail, "completion") {
t.Error("gvm answers the completion subcommand but the help does not mention it")
}
for _, f := range []string{"--complete-vms", "--complete-vcenters"} {
if strings.Contains(helpTail, f) {
t.Errorf("%s is called by the completion script and has no business in the help", f)
}
}
// And nothing is promised that is not answered.
for _, line := range strings.Split(helpTail, "\n") {
for _, word := range strings.Fields(line) {
if strings.HasPrefix(word, "--") && !isUpdateFlag(word) {
if !strings.HasPrefix(word, "--") {
continue
}
if !isUpdateFlag(word) && !isCompletionFlag(word) {
t.Errorf("the help offers %s, which nothing answers", word)
}
}
+34 -8
View File
@@ -9,9 +9,23 @@
#
# The file holds vCenter passwords, so it wants to be mode 0600 — gvm creates it
# that way and complains when it finds it readable by others.
#
# A password written here in the clear is sealed on the next run of gvm and
# replaced by a "gvmenc1:..." word, so it does not stand in this file where a
# backup, a synced home directory or an editor's swap file would pick it up.
# "gvm config -p v308" asks for one instead and writes it sealed straight away,
# which is the way to set one without it ever being on disk in the clear.
#
# What that is: the password is not in plain sight. What it is not: a vault.
# The key is compiled into gvm and is the same in every copy, so whoever holds
# this file *and* a gvm binary can open the value. The 0600 is what keeps other
# users out.
# The vCenter used when -v is not given. `gvm vm -l` ignores it and asks every
# configured server; every other command works on exactly one.
# The vCenter used when -v is not given. `gvm` and `gvm vm -l` ignore it and ask
# every configured server; the rest work on exactly one.
#
# -v takes a list for the commands that sweep — `-v v308,v108` — and refuses one
# for the commands that act on a single machine.
default = v308
# --- one 'vcenter.<name>.*' block per server ---
@@ -27,7 +41,7 @@ default = v308
vcenter.v308.url = https://v308.fhi.mpg.de/
vcenter.v308.user = administrator@v308.fhi.mpg.de
vcenter.v308.password = <password>
vcenter.v308.password = <password> # sealed on the next run
vcenter.v308.datacenter = PPB
vcenter.v308.insecure = true
@@ -43,22 +57,34 @@ vcenter.v38.password = <password>
vcenter.v38.datacenter = FEL
vcenter.v38.insecure = true
# --- mail, for `gvm log -m` ---
# Without these, `gvm log -m` says so before it queries anything.
# --- mail, for `gvm log -m` and `gvm snap --old -m` ---
# Without these, both say so before they query anything.
mailfrom = root@fhi.mpg.de
mailto = mw@pstbx.org
smtphost = m0.fhi-berlin.mpg.de
smtpport = 25
# --- telemetry, for `gvm host -t` ---
# Where the per-host numbers are posted. Unset (or without -t) nothing is sent.
# --- telemetry, for `gvm host -t` and `gvm ds -t` ---
# Where the per-host and per-datastore numbers are posted. Unset (or without
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- ssh, for the sheet's 'h' ---
# The command that logs in to a machine's guest from its sheet. "%h" is where
# the guest's own hostname — or its address, when it reports no name — is put;
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
# which is what one logs in to these machines as; a line here replaces it whole,
# root and all.
#
# The target is always one argument and never goes through a shell: it is a name
# the guest chose for itself, and gvm does not run it as a command.
# ssh = ssh -l someone %h
# --- the same settings from the environment ---
# Every setting above has an environment spelling that wins over the file:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY
# GVM_TELEMETRY, GVM_SSH
# GVM_VCENTER_<NAME>_<FIELD>, e.g. GVM_VCENTER_V308_PASSWORD
#
# which is the way to keep a password out of a file altogether — under cron,
+210
View File
@@ -0,0 +1,210 @@
// issues.go — the machines that want looking at.
//
// A list of two hundred machines is read by running the eye down it, which is
// exactly the wrong way to find the three that are broken: a lost VMware Tools,
// a filesystem at 97 %, a snapshot from March, a machine sitting on a question
// nobody has answered. Every one of those facts is already in the inventory
// sweep and none of them is visible in a table sorted by name.
//
// So this is not a new question put to the vCenters — it is a filter over the
// answer they have already given (^i in the list, `vm -l --issues` on the
// command line), and each machine carries the reason it is in the list.
//
// What counts as an issue is deliberately narrow. A list that cries wolf is one
// nobody opens, so a machine that is switched off is not an issue, a machine
// without VMware Tools is only worth a word while it is running, and a snapshot
// is only old once it has stopped being anybody's afternoon.
package main
import (
"strings"
"github.com/vmware/govmomi/vim25/types"
)
// When a guest filesystem is worth naming. Ninety per cent is where a disk
// stops having room for a surprise; ninety-five is where it stops having room.
// Small partitions sit legitimately close to full — /boot on a Debian is a
// perennial 92 % — which is why the mount point is always named with the figure
// rather than the machine merely being flagged.
const (
fsWarnPct = 90.0
fsBadPct = 95.0
)
// issue is one reason a machine is in the list. bad separates "this is broken"
// from "this wants a look" — the colours of the two are the palette's red and
// yellow, and the order they are reported in is worst first, because the column
// they end up in is the one that gets truncated.
type issue struct {
text string
bad bool
}
// issueList is everything gvm has to say against this machine, worst first.
//
// Everything here is read off the row as the sweep left it. Nothing in this
// function may go to the network: it is called for every machine in the
// inventory, for the table, for the filter and for the report.
func (r vmRow) issueList() []issue {
var bad, warn []issue
add := func(isBad bool, format string, a ...any) {
i := issue{text: SF(format, a...), bad: isBad}
if isBad {
bad = append(bad, i)
return
}
warn = append(warn, i)
}
rt := r.vm.Summary.Runtime
// vCenter cannot see the machine properly. Everything below this line is a
// statement about a machine vSphere is in touch with, so this comes first.
switch rt.ConnectionState {
case types.VirtualMachineConnectionStateConnected, "":
default:
add(true, "%s", string(rt.ConnectionState))
}
// A machine stopped on a question is stopped until somebody answers it, and
// nothing in the ordinary table says so.
if rt.Question != nil {
add(true, "waiting for an answer in vCenter")
}
// Delta disks left behind by a snapshot removal that did not finish. The
// machine runs perfectly well and grows quietly until the datastore is full.
if rt.ConsolidationNeeded {
add(true, "disks need consolidating")
}
// What vCenter itself is complaining about. Its own alarms are the best
// answer to "is something wrong", so they are passed on rather than
// second-guessed — by the name a person gave the alarm, never by its number.
alarms := 0
for _, a := range r.vm.TriggeredAlarmState {
if a.Acknowledged != nil && *a.Acknowledged {
continue // somebody has seen it and said so
}
switch a.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
case types.ManagedEntityStatusYellow:
add(false, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
}
}
// The overall status is the rollup of those alarms. It is only worth a line
// of its own when no alarm came with it — otherwise the same fact would be
// reported twice, once with a reason and once without.
if alarms == 0 {
switch r.vm.Summary.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "vCenter says red")
case types.ManagedEntityStatusYellow:
add(false, "vCenter says yellow")
}
}
if r.running() {
if !r.toolsRunning() {
add(false, "no VMware Tools")
}
for _, d := range r.fullDisks() {
add(d.pct >= fsBadPct, "%s %.0f %% full", d.path, d.pct)
}
}
// An old snapshot is the one issue here that is nobody's fault and
// everybody's job. The table's colours change at a week; the report only
// names one once it is a month old, so this list stays worth reading.
if e, ok := r.oldest(); ok {
if days := e.days(); days >= snapOldDays {
add(false, "snapshot %s is %s old", e.name, plural(days, "day"))
}
}
return append(bad, warn...)
}
// fullDisk is one guest filesystem that is nearly full.
type fullDisk struct {
path string
pct float64
}
// fullDisks are the guest's filesystems worth naming. The figures come from
// VMware Tools, so a machine without it simply has none — which is not the same
// as having none that are full, and is why the absence of Tools is its own line.
func (r vmRow) fullDisks() []fullDisk {
g := r.vm.Guest
if g == nil {
return nil
}
var out []fullDisk
for _, d := range g.Disk {
if d.Capacity <= 0 {
continue
}
pct := 100.0 - 100.0/float64(d.Capacity)*float64(d.FreeSpace)
if pct >= fsWarnPct {
out = append(out, fullDisk{path: d.DiskPath, pct: pct})
}
}
return out
}
// alarmLabel is the alarm's own name, or its reference when the names could not
// be read. "alarm-14 is red" is not something anybody can act on, but it is
// still better than not saying that something is.
func (r vmRow) alarmLabel(ref types.ManagedObjectReference) string {
if r.sess != nil {
if name := r.sess.alarms[ref]; name != "" {
return name
}
}
return ref.Value
}
// issues is the reasons as plain text, worst first.
func (r vmRow) issues() []string {
list := r.issueList()
out := make([]string, 0, len(list))
for _, i := range list {
out = append(out, i.text)
}
return out
}
func (r vmRow) hasIssues() bool { return len(r.issueList()) > 0 }
// issueCell is the WHY column: every reason, worst first, in one line for the
// column to truncate from the right. Truncation is why the order matters.
func (r vmRow) issueCell() string { return strings.Join(r.issues(), " · ") }
// issueColor paints the row's worst reason: red where something is broken,
// yellow where something wants a look.
func (r vmRow) issueColor() string {
for _, i := range r.issueList() {
if i.bad {
return colFull
}
}
if len(r.issueList()) > 0 {
return colBusy
}
return colOff
}
// withIssues is the filter itself.
func withIssues(rows []vmRow) []vmRow {
out := make([]vmRow, 0, len(rows))
for _, r := range rows {
if r.hasIssues() {
out = append(out, r)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// aged builds a snapshot entry that was taken so many days ago.
func aged(name string, days int) snapEntry {
when := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
return snapEntry{
ref: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "snapshot-" + name},
name: name,
when: when,
created: when.Local().Format("02.01.2006 15:04"),
}
}
// A machine with nothing wrong with it says nothing. This is the one that
// matters: the whole point of the filter is that it is short.
func TestAHealthyMachineHasNoIssues(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.issues(); len(got) > 0 {
t.Errorf("a healthy machine reported %v", got)
}
if r.hasIssues() {
t.Error("a healthy machine is in the issues list")
}
if r.issueColor() != colOff {
t.Error("a healthy machine's reason is coloured as though it had one")
}
}
// A machine that is switched off is not a fault, and the things that are only
// true of a running machine are not held against a stopped one.
func TestAStoppedMachineIsNotAnIssue(t *testing.T) {
r := testRow("web01", false, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100, FreeSpace: 1}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a stopped machine reported %v", got)
}
}
func TestIssuesFound(t *testing.T) {
for _, c := range []struct {
what string
bend func(*vmRow)
want string
bad bool
}{
{"disconnected", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateDisconnected
}, "disconnected", true},
{"orphaned", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateOrphaned
}, "orphaned", true},
{"a question", func(r *vmRow) {
r.vm.Summary.Runtime.Question = &types.VirtualMachineQuestionInfo{Id: "1"}
}, "waiting for an answer", true},
{"consolidation", func(r *vmRow) {
r.vm.Summary.Runtime.ConsolidationNeeded = true
}, "consolidating", true},
{"a red status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
}, "vCenter says red", true},
{"a yellow status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusYellow
}, "vCenter says yellow", false},
{"no tools", func(r *vmRow) {
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: "guestToolsNotRunning"}
}, "no VMware Tools", false},
{"a full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/var", Capacity: 100 << 30, FreeSpace: 3 << 30}}
}, "/var 97 % full", true},
{"a nearly full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/boot", Capacity: 100 << 30, FreeSpace: 8 << 30}}
}, "/boot 92 % full", false},
{"an old snapshot", func(r *vmRow) {
r.snaps = []snapEntry{aged("before-patch", 63)}
}, "before-patch is 63 days old", false},
} {
r := testRow("web01", true, "10.0.0.5")
c.bend(&r)
list := r.issueList()
found := false
for _, i := range list {
if strings.Contains(i.text, c.want) {
found = true
if i.bad != c.bad {
t.Errorf("%s: bad = %v, want %v (%q)", c.what, i.bad, c.bad, i.text)
}
}
}
if !found {
t.Errorf("%s: nothing said %q, only %v", c.what, c.want, r.issues())
}
if !r.hasIssues() {
t.Errorf("%s: the machine is not in the issues list", c.what)
}
}
}
// A filesystem that is merely fairly full is nobody's business.
func TestAFilesystemWithRoomIsNotReported(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100 << 30, FreeSpace: 20 << 30}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a filesystem at 80 %% reported %v", got)
}
}
// A snapshot is only old once it has stopped being somebody's afternoon. The
// table colours it yellow after a week; the list of things to answer for waits
// for a month, or it fills up with this morning's work.
func TestOnlyAMonthOldSnapshotIsAnIssue(t *testing.T) {
for _, c := range []struct {
days int
want bool
}{{2, false}, {snapStaleDays + 1, false}, {snapOldDays, true}, {90, true}} {
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", c.days)}
if got := r.hasIssues(); got != c.want {
t.Errorf("a snapshot of %d days: reported = %v, want %v (%v)",
c.days, got, c.want, r.issues())
}
}
}
// vCenter's own alarms are passed on by the name somebody gave them, and an
// alarm that has been acknowledged has been dealt with by a person already.
func TestAlarms(t *testing.T) {
ref := types.ManagedObjectReference{Type: "Alarm", Value: "alarm-14"}
yes := true
r := testRow("web01", true, "10.0.0.5")
r.vm.TriggeredAlarmState = []types.AlarmState{
{Alarm: ref, OverallStatus: types.ManagedEntityStatusRed},
}
// Without the names, the reference is still said: it is little use, but it
// is not silence.
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "alarm-14") {
t.Errorf("an alarm with no name resolved reported %q", got)
}
r.sess = &session{alarms: map[types.ManagedObjectReference]string{ref: "Host memory usage"}}
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "Host memory usage") {
t.Errorf("the alarm's name was not used: %q", got)
}
r.vm.TriggeredAlarmState[0].Acknowledged = &yes
if got := r.issues(); len(got) > 0 {
t.Errorf("an acknowledged alarm still reported %v", got)
}
}
// The rolled-up status is not reported next to the alarm it is the rollup of:
// the same fact twice, once with a reason and once without.
func TestTheStatusIsNotReportedTwice(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
r.vm.TriggeredAlarmState = []types.AlarmState{{
Alarm: types.ManagedObjectReference{Type: "Alarm", Value: "alarm-1"},
OverallStatus: types.ManagedEntityStatusRed,
}}
if got := r.issues(); len(got) != 1 {
t.Errorf("a red machine with one alarm reported %d things: %v", len(got), got)
}
if got := strings.Join(r.issues(), " "); strings.Contains(got, "says red") {
t.Errorf("the rollup was reported beside its own alarm: %q", got)
}
}
// Worst first, because the column they end up in is truncated from the right.
func TestTheWorstReasonComesFirst(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // a word of warning
r.vm.Summary.Runtime.ConsolidationNeeded = true // broken
list := r.issueList()
if len(list) < 2 {
t.Fatalf("expected both reasons, got %v", r.issues())
}
if !list[0].bad {
t.Errorf("the reasons came out warning first: %v", r.issues())
}
if r.issueColor() != colFull {
t.Error("a machine with something broken is not painted as broken")
}
// Only a warning: yellow, not red.
r.vm.Summary.Runtime.ConsolidationNeeded = false
if r.issueColor() != colBusy {
t.Error("a machine with only a warning is painted as broken")
}
if cell := r.issueCell(); !strings.Contains(cell, "no VMware Tools") {
t.Errorf("the reason column says %q", cell)
}
}
func TestWithIssuesKeepsOnlyTheOnesToAnswerFor(t *testing.T) {
good := testRow("web01", true, "10.0.0.5")
bad := testRow("db01", true, "10.0.0.6")
bad.vm.Summary.Runtime.ConsolidationNeeded = true
got := withIssues([]vmRow{good, bad})
if len(got) != 1 || got[0].name != "db01" {
t.Errorf("the filter kept %d machines: %v", len(got), got)
}
}
+180
View File
@@ -0,0 +1,180 @@
// jsonout.go — the machine listing as a document.
//
// `gvm vm -l` is meant to be read; this is the same sweep meant to be parsed —
// by a monitoring check, a report, a spreadsheet. So the shape here is a
// promise, and two decisions follow from that.
//
// It is one object and not an array of machines, because a listing that leaves
// out a vCenter which did not answer is worse than no listing at all: a script
// handed a bare array cannot tell an empty cluster from an unreachable one. The
// servers that answered and the ones that did not are part of the document.
//
// And a figure that is not known is null, never zero. A stopped machine has no
// processor load, a machine whose guest is silent has no address, and a
// spreadsheet that averages a column of zeroes reports a fleet that is idle.
package main
import (
"encoding/json"
"os"
"time"
)
// jsonListing is the whole document.
type jsonListing struct {
Generated string `json:"generated"`
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []jsonMachine `json:"machines"`
}
// jsonMachine is one machine. The names are the ones the table's headers stand
// for, spelled out: a document is read by somebody who cannot see the header.
type jsonMachine struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Datacenter string `json:"datacenter"`
Host string `json:"host"`
Power string `json:"power"`
Connection string `json:"connection,omitempty"`
Status string `json:"status,omitempty"` // vCenter's own green/yellow/red
Template bool `json:"template"`
Address string `json:"address,omitempty"`
Hostname string `json:"hostname,omitempty"`
Guest string `json:"guest,omitempty"`
ToolsRunning bool `json:"tools_running"`
CPUs int32 `json:"cpus"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryMB int32 `json:"memory_mb"`
MemoryPercent *float64 `json:"memory_percent"`
UptimeSeconds *int32 `json:"uptime_seconds"`
CommittedBytes *int64 `json:"committed_bytes"`
UncommittedBytes *int64 `json:"uncommitted_bytes"`
Snapshots []jsonSnapshot `json:"snapshots"`
OldestSnapshotAt *string `json:"oldest_snapshot_at"`
OldestSnapshotDays *int `json:"oldest_snapshot_days"`
Task *jsonTask `json:"task"`
Issues []string `json:"issues"`
UUID string `json:"uuid,omitempty"`
Instance string `json:"instance_uuid,omitempty"`
Moref string `json:"moref"`
}
type jsonSnapshot struct {
Name string `json:"name"`
Created string `json:"created"`
Days int `json:"days"`
Current bool `json:"current"`
Depth int `json:"depth"`
}
type jsonTask struct {
What string `json:"what"`
Queued bool `json:"queued"`
Progress int32 `json:"progress"`
Since string `json:"since,omitempty"`
}
// printJSON writes the document. Indented, because the first reader of it is
// always a person finding out what the keys are called.
func printJSON(found sweep, rows []vmRow) error {
doc := jsonListing{
Generated: time.Now().Format(time.RFC3339),
Answered: found.answered,
Failed: found.failed,
Count: len(rows),
Machines: make([]jsonMachine, 0, len(rows)),
}
if doc.Answered == nil {
doc.Answered = []string{}
}
if doc.Failed == nil {
doc.Failed = []string{}
}
for _, r := range rows {
doc.Machines = append(doc.Machines, jsonOf(r))
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
return errf("cannot write the listing: %w", err)
}
return nil
}
// jsonOf is one row as a document entry.
func jsonOf(r vmRow) jsonMachine {
sum := r.vm.Summary
cfg := sum.Config
rt := sum.Runtime
m := jsonMachine{
Name: r.name,
VCenter: r.vc.Name,
Datacenter: r.vc.Datacenter,
Host: r.host,
Power: string(r.power()),
Connection: string(rt.ConnectionState),
Status: string(sum.OverallStatus),
Template: cfg.Template,
Guest: r.guestOS(),
ToolsRunning: r.toolsRunning(),
CPUs: cfg.NumCpu,
MemoryMB: cfg.MemorySizeMB,
Issues: r.issues(),
UUID: cfg.Uuid,
Instance: cfg.InstanceUuid,
Moref: r.ref.Value,
Snapshots: []jsonSnapshot{},
}
if m.Issues == nil {
m.Issues = []string{}
}
if ip := r.ip(); ip != "-" {
m.Address = ip
}
if g := r.vm.Guest; g != nil {
m.Hostname = g.HostName
}
if pct, ok := r.cpuLoad(); ok {
m.CPUPercent = &pct
}
if pct, ok := r.memLoad(); ok {
m.MemoryPercent = &pct
}
if r.running() && sum.QuickStats.UptimeSeconds > 0 {
up := sum.QuickStats.UptimeSeconds
m.UptimeSeconds = &up
}
if st := sum.Storage; st != nil {
committed, uncommitted := st.Committed, st.Uncommitted
m.CommittedBytes, m.UncommittedBytes = &committed, &uncommitted
}
for _, e := range r.snaps {
m.Snapshots = append(m.Snapshots, jsonSnapshot{
Name: e.name, Created: e.when.Format(time.RFC3339),
Days: e.days(), Current: e.current, Depth: e.depth,
})
}
if e, ok := r.oldest(); ok {
at, days := e.when.Format(time.RFC3339), e.days()
m.OldestSnapshotAt, m.OldestSnapshotDays = &at, &days
}
if t := r.task; t != nil {
jt := jsonTask{What: t.what, Queued: t.queued, Progress: t.progress}
if !t.since.IsZero() {
jt.Since = t.since.Format(time.RFC3339)
}
m.Task = &jt
}
return m
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/types"
)
// jsonDoc runs the encoder over a listing and reads it back the way whatever is
// on the other end of the pipe would.
func jsonDoc(t *testing.T, found sweep, rows []vmRow) map[string]any {
t.Helper()
out := captureStdout(t, func() {
if err := printJSON(found, rows); err != nil {
t.Fatal(err)
}
})
var doc map[string]any
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("what came out is not JSON: %v\n%s", err, out)
}
return doc
}
// A figure that is not known is null and never nought. A stopped machine has no
// processor load, and a spreadsheet that averages a column of zeroes reports a
// fleet that is idle.
func TestJSONLeavesTheUnknownNull(t *testing.T) {
on := testRow("web01", true, "10.0.0.5")
off := testRow("db01", false, "")
off.ref = types.ManagedObjectReference{Value: "vm-43"}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{on, off})
machines := doc["machines"].([]any)
if len(machines) != 2 {
t.Fatalf("the document holds %d machines", len(machines))
}
running := machines[0].(map[string]any)
stopped := machines[1].(map[string]any)
if running["cpu_percent"] == nil {
t.Error("a running machine has no processor load in the document")
}
for _, key := range []string{"cpu_percent", "memory_percent", "uptime_seconds"} {
if stopped[key] != nil {
t.Errorf("a stopped machine reports %s = %v, want null", key, stopped[key])
}
}
if stopped["address"] != nil {
t.Errorf("a machine with no address reports address = %v", stopped["address"])
}
}
// The servers that answered and the ones that did not are part of the document.
// A script handed a bare list of machines cannot tell an empty cluster from an
// unreachable one, which is the difference that matters.
func TestJSONNamesTheServersThatDidNotAnswer(t *testing.T) {
doc := jsonDoc(t, sweep{
answered: []string{"v308"},
failed: []string{"v108: login failed"},
}, nil)
if got := doc["answered"].([]any); len(got) != 1 || got[0] != "v308" {
t.Errorf("answered = %v", got)
}
got := doc["failed"].([]any)
if len(got) != 1 || !strings.Contains(got[0].(string), "v108") {
t.Errorf("failed = %v", got)
}
}
// Both lists are always there, empty rather than absent: a reader that has to
// tell null from [] is a reader that will get it wrong once.
func TestJSONAlwaysHasBothServerLists(t *testing.T) {
doc := jsonDoc(t, sweep{}, nil)
for _, key := range []string{"answered", "failed", "machines"} {
if doc[key] == nil {
t.Errorf("%s is null in an empty listing", key)
}
}
if doc["count"] != float64(0) {
t.Errorf("count = %v", doc["count"])
}
}
// What the table shows in colour and what the document says in words is the
// same judgement, made in one place.
func TestJSONCarriesTheIssuesAndTheSnapshots(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true
r.snaps = []snapEntry{aged("before-patch", 63), aged("hotfix", 2)}
r.task = &runningTask{what: "clone", progress: 40}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{r})
m := doc["machines"].([]any)[0].(map[string]any)
issues := m["issues"].([]any)
if len(issues) == 0 || !strings.Contains(issues[0].(string), "consolidating") {
t.Errorf("issues = %v", issues)
}
if got := m["snapshots"].([]any); len(got) != 2 {
t.Errorf("the document holds %d snapshots", len(got))
}
if got := m["oldest_snapshot_days"]; got != float64(63) {
t.Errorf("oldest_snapshot_days = %v", got)
}
task := m["task"].(map[string]any)
if task["what"] != "clone" || task["progress"] != float64(40) {
t.Errorf("task = %v", task)
}
// And a machine with none of those says so, rather than leaving the reader
// to guess whether the key was simply left out.
quiet := testRow("db01", true, "10.0.0.6")
doc = jsonDoc(t, sweep{}, []vmRow{quiet})
m = doc["machines"].([]any)[0].(map[string]any)
if got := m["issues"].([]any); len(got) != 0 {
t.Errorf("a healthy machine reports issues = %v", got)
}
if m["task"] != nil {
t.Errorf("an idle machine reports task = %v", m["task"])
}
if m["oldest_snapshot_at"] != nil {
t.Errorf("a machine with no snapshots reports oldest_snapshot_at = %v", m["oldest_snapshot_at"])
}
}
// The document is one object, not a bare array: that is what leaves room for
// the servers, and it is the promise a script is written against.
func TestJSONIsOneDocument(t *testing.T) {
out := captureStdout(t, func() {
if err := printJSON(sweep{}, nil); err != nil {
t.Fatal(err)
}
})
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("the document begins %q", strings.SplitN(out, "\n", 2)[0])
}
}
+22
View File
@@ -105,3 +105,25 @@ func paint(text, col string) string {
}
return col + text + attrOff
}
// plainRow is one row with nothing in it but the values — the same cells and
// the same widths as printRow writes to the screen, without the colour.
//
// For output that leaves the machine: the mail a report sends is read in a mail
// client, where an escape sequence is not a colour but four stray characters,
// and fatih/color's answer to "is this a terminal" is about this process's
// stdout and says nothing about where a mail is going.
func plainRow(cols []printColumn, cells []cell) string {
out := make([]string, 0, len(cols))
for i, c := range cols {
text := c.header
if cells != nil {
if i >= len(cells) {
break
}
text = cells[i].text
}
out = append(out, pad(text, c.width, c.right))
}
return strings.TrimRight(strings.Join(out, " "), " ")
}
+126
View File
@@ -0,0 +1,126 @@
// seal.go — the passwords in ~/.gvmrc, not in plain sight.
//
// A sealed value looks like this, and the rest of the file stays as it was:
//
// vcenter.v308.password = gvmenc1:Lb2h…
//
// Only the value is sealed, never the file: urls, users, datacenters and the mail
// settings stay readable and the file stays editable by hand, comments and all.
// AES-256-GCM, the key derived per value with HKDF from a random salt, all of it
// packed into one base64 word.
//
// What this is and is not, plainly. FILEKEY is compiled into gvm and is the same
// in every copy of it, so whoever holds ~/.gvmrc *and* a gvm binary can open the
// value; prising the key out is an afternoon's work, not a cluster's. This is not
// a vault and it is not meant to be one. What it buys is that the password no
// longer stands in the clear in a backup, in a home directory that syncs
// somewhere, in an editor's swap file, or on a screen someone else is looking
// at — which is what was asked for. The file stays 0600 for the rest.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// FILEKEY is what the values in ~/.gvmrc are sealed under: thirty-two random
// bytes, the same in every build so that a file written by one gvm opens in the
// next. There is nothing to guess here and so no reason to slow a guesser down —
// HKDF, not argon2, and a value opens in microseconds.
//
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
// Values written by earlier builds then no longer open, and gvm says so and names
// the vCenter whose password has to be entered again.
var FILEKEY = "8Vb0MUm04VP/aOZTTSGcqdN9NbNC6CETAhSXu1hwbIk="
const (
// sealTag marks a sealed value and leaves room to tell it apart from whatever
// a later version writes, should the scheme ever have to change.
sealTag = "gvmenc1:"
saltLen = 16
keyLen = 32
sealInfo = "gvmrc password"
)
// sealed reports whether a value is one, which is how gvm knows a password in the
// file still stands in the clear and wants sealing.
func sealed(value string) bool { return strings.HasPrefix(value, sealTag) }
// seal turns a password into the word that goes in the file.
func seal(secret string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", errf("cannot seal the password: %w", err)
}
gcm, err := sealGCM(salt)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", errf("cannot seal the password: %w", err)
}
// salt, nonce and the sealed bytes travel together: opening it needs all
// three and nothing else, so one word in the file is the whole story.
blob := append(salt, nonce...)
blob = gcm.Seal(blob, nonce, []byte(secret), nil)
return sealTag + base64.StdEncoding.EncodeToString(blob), nil
}
// unseal turns it back. A value that is not sealed comes back unchanged: that is
// how a password typed straight into the file, or handed over in the environment,
// keeps working.
func unseal(value string) (string, error) {
if !sealed(value) {
return value, nil
}
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, sealTag))
if err != nil {
return "", errf("the sealed password is not readable: %w", err)
}
gcm, err := sealGCM(nil)
if err != nil {
return "", err
}
if len(blob) < saltLen+gcm.NonceSize() {
return "", errf("the sealed password is too short to be one")
}
salt, rest := blob[:saltLen], blob[saltLen:]
nonce, box := rest[:gcm.NonceSize()], rest[gcm.NonceSize():]
gcm, err = sealGCM(salt)
if err != nil {
return "", err
}
secret, err := gcm.Open(nil, nonce, box, nil)
if err != nil {
return "", errf("the sealed password does not open — it was sealed by a gvm " +
"built with another key, or it has been altered; enter it again")
}
return string(secret), nil
}
// sealGCM derives the key for one value and wraps it. A nil salt is allowed so a
// caller may ask for the nonce size before it knows the salt.
func sealGCM(salt []byte) (cipher.AEAD, error) {
root, err := base64.StdEncoding.DecodeString(FILEKEY)
if err != nil || len(root) == 0 {
return nil, errf("this gvm was built without a usable key for sealing passwords")
}
key, err := hkdf.Key(sha256.New, root, salt, sealInfo, keyLen)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
return cipher.NewGCM(block)
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSealRoundTrip(t *testing.T) {
for _, secret := range []string{
"hunter2",
"",
"mit Leerzeichen und Ümläuten",
"a/b+c=d", // the characters base64 uses, to be sure nothing is confused
"gvmenc1:nearly", // a password that looks like a sealed value
strings.Repeat("x", 500),
} {
word, err := seal(secret)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if !sealed(word) {
t.Errorf("%q sealed to something unmarked: %q", secret, word)
}
if secret != "" && strings.Contains(word, secret) {
t.Errorf("%q is readable in its own sealed form: %q", secret, word)
}
back, err := unseal(word)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if back != secret {
t.Errorf("came back as %q, want %q", back, secret)
}
}
}
// A fresh salt and nonce each time, so two machines with the same password do not
// show the same word in the file — which would say they share one.
func TestSealIsDifferentEveryTime(t *testing.T) {
a, _ := seal("gleich")
b, _ := seal("gleich")
if a == b {
t.Error("the same password sealed twice gave the same word")
}
}
// A value that is not sealed is handed back as it is: that is how a password
// typed straight into the file, or given in the environment, keeps working.
func TestUnsealLeavesPlainValuesAlone(t *testing.T) {
for _, plain := range []string{"hunter2", "", "gvmenc", "gvmenc1"} {
got, err := unseal(plain)
if err != nil {
t.Errorf("%q: %v", plain, err)
}
if got != plain {
t.Errorf("%q came back as %q", plain, got)
}
}
}
// Something that says it is sealed and is not must be an error, never an empty
// password — that would reach vCenter and look like the wrong one.
func TestBrokenSealIsAnError(t *testing.T) {
good, _ := seal("hunter2")
for _, c := range []struct{ value, note string }{
{sealTag + "not base64 at all!!", "not base64"},
{sealTag, "nothing after the tag"},
{sealTag + "c2hvcnQ=", "too short to hold a salt"},
{good[:len(good)-4] + "AAAA", "altered"},
} {
got, err := unseal(c.value)
if err == nil {
t.Errorf("%s: opened to %q instead of failing", c.note, got)
}
if got != "" {
t.Errorf("%s: gave back %q as well as an error", c.note, got)
}
}
// And the message says what to do about it.
if _, err := unseal(good[:len(good)-4] + "AAAA"); err == nil ||
!strings.Contains(err.Error(), "enter it again") {
t.Errorf("the message does not say what to do: %v", err)
}
}
// The vCenter's own accessor names itself in the error, so a file with three
// servers says which one is the trouble.
func TestVCenterPasswordNamesItself(t *testing.T) {
word, _ := seal("hunter2")
v := VCenter{Name: "v308", Password: word}
if got, err := v.password(); err != nil || got != "hunter2" {
t.Errorf("password() gave %q, %v", got, err)
}
broken := VCenter{Name: "v308", Password: sealTag + "rubbish"}
_, err := broken.password()
if err == nil {
t.Fatal("a broken seal came back without an error")
}
if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
// The file rewriting. Everything but the password itself has to survive.
func TestSealPasswordsRewritesOnlyTheSecret(t *testing.T) {
const before = `# my configuration
default = v308
vcenter.v308.url = https://v308.example/
vcenter.v308.user = administrator@v308
vcenter.v308.password = hunter2 # the password
vcenter.v308.datacenter = PPB
vcenter.v108.password = "with spaces"
vcenter.v108.user = admin
# vcenter.old.password = leave-me-alone
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
quiet(t)
sealPasswords(path, before)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(after)
// The secrets are gone and the rest is untouched, line for line.
for _, gone := range []string{"= hunter2", "with spaces"} {
if strings.Contains(got, gone) {
t.Errorf("%q still stands in the clear:\n%s", gone, got)
}
}
for _, kept := range []string{
"# my configuration", "default = v308",
"vcenter.v308.url = https://v308.example/",
"vcenter.v308.user = administrator@v308",
"vcenter.v308.datacenter = PPB",
"# the password", // the note beside it is the user's
"# vcenter.old.password = leave-me-alone", // a commented-out line is not a setting
"vcenter.v108.user = admin",
"mailto = me@example.org",
} {
if !strings.Contains(got, kept) {
t.Errorf("the rewrite lost %q:\n%s", kept, got)
}
}
if strings.Count(got, "\n") != strings.Count(before, "\n") {
t.Errorf("the number of lines changed:\n%s", got)
}
// Both passwords open again, and to what they were.
cfg := Config{}
applyConfig(&cfg, parseConfig(got))
want := map[string]string{"v308": "hunter2", "v108": "with spaces"}
for _, v := range cfg.VCenters {
if !sealed(v.Password) {
t.Errorf("%s was not sealed", v.Name)
continue
}
if secret, err := v.password(); err != nil || secret != want[v.Name] {
t.Errorf("%s opens to %q, %v — want %q", v.Name, secret, err, want[v.Name])
}
}
// And a second pass changes nothing at all.
sealPasswords(path, got)
again, _ := os.ReadFile(path)
if string(again) != got {
t.Errorf("sealing twice changed the file the second time:\n%s", string(again))
}
// The file it writes is still readable by nobody else.
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Errorf("the rewritten file is mode %04o", fi.Mode().Perm())
}
}
func TestWriteSettingReplacesOrAppends(t *testing.T) {
const before = `# top
vcenter.v308.user = admin
vcenter.v308.password = old
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
if err := writeSetting(path, "vcenter.v308.password", "new"); err != nil {
t.Fatal(err)
}
got := readFile(t, path)
if !strings.Contains(got, "vcenter.v308.password = new") {
t.Errorf("the setting was not replaced:\n%s", got)
}
if strings.Contains(got, "= old") {
t.Errorf("the old value is still there:\n%s", got)
}
for _, kept := range []string{"# top", "vcenter.v308.user = admin", "mailto = me@example.org"} {
if !strings.Contains(got, kept) {
t.Errorf("writing lost %q:\n%s", kept, got)
}
}
// A setting that is not there yet is added rather than lost.
if err := writeSetting(path, "vcenter.v108.password", "brandnew"); err != nil {
t.Fatal(err)
}
if got := readFile(t, path); !strings.Contains(got, "vcenter.v108.password = brandnew") {
t.Errorf("a new setting was not added:\n%s", got)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// What `gvm config` says about a password, without saying the password.
func TestPasswordState(t *testing.T) {
word, _ := seal("hunter2")
for _, c := range []struct {
v VCenter
want string
note string
}{
{VCenter{Name: "a", Password: word}, "password sealed", "a sealed one"},
{VCenter{Name: "b", Password: "hunter2"}, "in the clear", "one still in the clear"},
{VCenter{Name: "c", Password: sealTag + "rubbish"}, "does not open", "one that will not open"},
} {
got := stripEscapes(passwordState(c.v))
if !strings.Contains(got, c.want) {
t.Errorf("%s: %q does not say %q", c.note, got, c.want)
}
if strings.Contains(got, "hunter2") {
t.Errorf("%s: the password itself is in the output: %q", c.note, got)
}
}
}
// The claim of this whole file: what goes on the wire is the *opened* password,
// never the sealed word out of ~/.gvmrc.
//
// Checked at loginURL rather than against a server, because govmomi's simulator
// accepts any non-empty password by default — a login that succeeds there proves
// nothing at all about which password was sent.
func TestLoginURLCarriesTheOpenedPassword(t *testing.T) {
const secret = "the-real-one"
word, err := seal(secret)
if err != nil {
t.Fatal(err)
}
if strings.Contains(word, secret) {
t.Fatal("the sealed word contains the password, so this would prove nothing")
}
v := VCenter{
Name: "v308", URL: "https://v308.example/",
User: "administrator@v308", Password: word,
}
u, err := loginURL(v)
if err != nil {
t.Fatalf("loginURL: %v", err)
}
got, ok := u.User.Password()
if !ok {
t.Fatal("the url carries no password at all")
}
if got != secret {
t.Errorf("the url carries %q, want the opened password", got)
}
if got == word {
t.Error("the sealed word itself was put in the url")
}
if u.User.Username() != "administrator@v308" {
t.Errorf("the user is %q", u.User.Username())
}
if u.String() == "" || !strings.HasSuffix(u.Path, "/sdk") {
t.Errorf("the endpoint is %q", u.Path)
}
// A password still in the clear goes through untouched, so a file nobody has
// let gvm rewrite yet keeps working.
plain := v
plain.Password = "still-plain"
u, err = loginURL(plain)
if err != nil {
t.Fatal(err)
}
if got, _ := u.User.Password(); got != "still-plain" {
t.Errorf("a plain password came through as %q", got)
}
// And one that will not open never gets as far as a url.
broken := v
broken.Password = sealTag + "rubbish"
if u, err := loginURL(broken); err == nil {
got, _ := u.User.Password()
t.Errorf("a broken seal produced a url carrying %q", got)
} else if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
+402 -3
View File
@@ -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
@@ -269,10 +276,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
vc := simVCenter(t)
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
if err := lsvm([]VCenter{vc}, "", "", false); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{}); err != nil {
t.Errorf("lsvm: %v", err)
}
if err := lsvm([]VCenter{vc}, "DC0", "", false); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "DC0"}); err != nil {
t.Errorf("lsvm with a pattern: %v", err)
}
if err := hoststat(vc, ""); err != nil {
@@ -286,7 +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(", "", false); err == nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "web("}); err == nil {
t.Error("lsvm accepted a broken pattern")
}
}
@@ -962,3 +969,395 @@ func TestSimAllVCentersUnreachable(t *testing.T) {
t.Errorf("answered: %v", found.answered)
}
}
// ------------------------------------------------- what the new commands read
// The sweep brings the snapshots back with the machines, which is what the
// snapshot column, the issues filter and the age report are all made of.
func TestSimSweepCarriesTheSnapshots(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
if err := snapshotNow(s, r.ref, "sweep-test", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
found, err := gatherVMs([]VCenter{vc})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
for _, row := range found.rows {
if row.name != "DC0_C0_RP0_VM0" {
continue
}
if row.snapCount() != 1 {
t.Fatalf("the sweep found %d snapshots, want 1", row.snapCount())
}
if row.snapCell() != "1" {
t.Errorf("the column shows %q", row.snapCell())
}
e, ok := row.oldest()
if !ok {
t.Fatal("the snapshot came back without a date")
}
if e.name != "sweep-test" {
t.Errorf("the oldest snapshot is %q", e.name)
}
// Taken a moment ago, so it is neither stale nor an issue.
if days := e.days(); days != 0 {
t.Errorf("a snapshot taken just now is %d days old", days)
}
if row.hasIssues() && strings.Contains(strings.Join(row.issues(), " "), "snapshot") {
t.Errorf("a fresh snapshot is held against the machine: %v", row.issues())
}
return
}
t.Fatal("the machine was not in the sweep")
}
// The age report, end to end: a snapshot taken now is not old, and the same
// report with an age of nothing finds it.
func TestSimSnapshotAgeReport(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if err := snapshotNow(s, r.ref, "ancient", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
// Nothing is a month old on a vCenter that was created a second ago — and
// where somebody is reading, the report says so rather than printing an
// empty table.
onATerminal(t)
out := captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, snapOldDays, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("the report of old snapshots says:\n%s", out)
}
// Everything is nought days old, so an age of nought finds it.
out = captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, 0, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
for _, want := range []string{"ancient", "DC0_C0_RP0_VM1", "0d"} {
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the report leaves out %q:\n%s", want, out)
}
}
}
// A mailed report with no relay configured is refused before the sweep, not
// after it: finding out that the mail cannot be sent is of no use once the
// report has been printed.
func TestSimSnapshotReportChecksTheMailFirst(t *testing.T) {
quiet(t)
err := snapOldReport(Config{}, []VCenter{{Name: "nowhere", URL: "https://127.0.0.1:1/",
User: "u", Password: "p", Datacenter: "DC0"}}, 30, true)
if err == nil || !strings.Contains(err.Error(), "cannot send mail") {
t.Errorf("a report with -m and no relay failed with: %v", err)
}
}
func TestSimDatastores(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := dsstat(vc, ""); err != nil {
t.Fatalf("dsstat: %v", err)
}
})
plain := stripEscapes(out)
for _, want := range []string{"DATASTORE", "CAPACITY", "USED%", "LocalDS_0", "datastore"} {
if !strings.Contains(plain, want) {
t.Errorf("the datastore table leaves out %q:\n%s", want, plain)
}
}
}
// The printed listing as a document: what a monitoring check would read.
func TestSimJSONListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Moref string `json:"moref"`
Power string `json:"power"`
} `json:"machines"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("the listing is not JSON: %v\n%s", err, out)
}
if len(doc.Answered) != 1 || doc.Answered[0] != "sim" {
t.Errorf("answered = %v", doc.Answered)
}
if doc.Count != len(doc.Machines) || doc.Count == 0 {
t.Errorf("count = %d, machines = %d", doc.Count, len(doc.Machines))
}
for _, m := range doc.Machines {
if m.Name == "" || m.Moref == "" || m.VCenter != "sim" || m.Power == "" {
t.Errorf("a machine came out as %+v", m)
}
}
}
// A vCenter that does not answer is in the document rather than printed into
// the middle of it, where it would break whatever is reading it.
func TestSimJSONKeepsTheProseOut(t *testing.T) {
quiet(t)
vc := simVCenter(t)
dead := VCenter{Name: "dead", URL: "https://127.0.0.1:1/", User: "u", Password: "p", Datacenter: "DC0"}
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc, dead}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("a failed server made the document unreadable: %v\n%s", err, out)
}
if len(doc.Failed) != 1 || !strings.Contains(doc.Failed[0], "dead") {
t.Errorf("failed = %v", doc.Failed)
}
if strings.Contains(out, "ERROR") {
t.Errorf("the failure was printed as prose as well:\n%s", out)
}
}
// The issues listing runs against a real inventory. The simulated machines have
// no VMware Tools, which is exactly the kind of thing it is for.
func TestSimIssuesListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
plain := stripEscapes(out)
if !strings.Contains(plain, "WHY") {
t.Errorf("the issues listing has no reason column:\n%s", plain)
}
if !strings.Contains(plain, "VMware Tools") {
t.Errorf("the running machines without Tools were not reported:\n%s", plain)
}
}
// The machine's own events, which is what 'e' on a machine's sheet fetches.
func TestSimEventsOfOneMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
// Something to find: a snapshot leaves events behind it.
if err := snapshotNow(s, r.ref, "for-the-log", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
lines, err := eventsOf(r)
if err != nil {
t.Fatalf("eventsOf: %v", err)
}
for _, l := range lines {
if strings.TrimSpace(l.text) == "" {
t.Error("an event came back with nothing in it")
}
if strings.ContainsAny(l.text, "\n\r") {
t.Errorf("an event carries a newline, which would break the sheet: %q", l.text)
}
}
// And they go on the sheet under one label, in their own colours.
sheet := eventSheet(lines)
if len(sheet) != len(lines) {
t.Errorf("the sheet holds %d of %d events", len(sheet), len(lines))
}
if len(sheet) > 0 && sheet[0].label != "events" {
t.Errorf("the first event line is labelled %q", sheet[0].label)
}
for _, l := range sheet[1:] {
if l.label != "" {
t.Errorf("an event line carries a second label: %q", l.label)
}
}
}
// A machine read over a connection that has gone cannot be asked for anything,
// and says so rather than looking empty.
func TestSimEventsWithoutAConnection(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if _, err := eventsOf(r); err == nil {
t.Error("a row with no session read its events anyway")
}
}
// The instance UUID is what the vSphere client's links are made of, and the one
// thing gvm cannot work out from the configuration.
func TestSimVsphereLinkFromASession(t *testing.T) {
quiet(t)
vc := simVCenter(t)
_, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
url := vsphereURL(r)
if url == "" {
t.Fatal("no link was built from a live connection")
}
if !strings.Contains(url, "urn:vmomi:VirtualMachine:"+r.ref.Value+":") {
t.Errorf("the link does not name the machine: %s", url)
}
if !strings.HasSuffix(url, "/summary") {
t.Errorf("the link does not end at the machine's page: %s", url)
}
}
// -v takes several servers for the sweeps, and the same server twice is still
// one login.
func TestMultipleVCentersInOneSweep(t *testing.T) {
cfg := Config{VCenters: []VCenter{
{Name: "v308", URL: "https://a/", User: "u", Password: "p", Datacenter: "DC"},
{Name: "v108", URL: "https://b/", User: "u", Password: "p", Datacenter: "DC"},
{Name: "v38", URL: "https://c/", User: "u", Password: "p", Datacenter: "DC"},
}}
got, err := cfg.targets("v308,v38")
if err != nil {
t.Fatalf("targets: %v", err)
}
if len(got) != 2 || got[0].Name != "v308" || got[1].Name != "v38" {
t.Errorf("-v v308,v38 gave %v", vcNames(got))
}
// The order given is the order used, and a name given twice is one server.
got, _ = cfg.targets("v38, v308 ,v38")
if len(got) != 2 || got[0].Name != "v38" || got[1].Name != "v308" {
t.Errorf("-v with a repeat gave %v", vcNames(got))
}
// An unknown name among them is an error, not a shorter list: a sweep that
// quietly left a server out would report a cluster that is not there.
if _, err := cfg.targets("v308,nowhere"); err == nil {
t.Error("an unknown server in the list was skipped")
}
// And the commands that act on one machine refuse a list outright.
if _, err := cfg.pick("v308,v38"); err == nil {
t.Error("a single-server command took a list of servers")
} else if !strings.Contains(err.Error(), "one vCenter at a time") {
t.Errorf("it refused with: %v", err)
}
}
// The task path itself: that the references off a machine can be read back as
// tasks at all. runningTasks swallows a failure here on purpose — a table is
// worth having without the column — so a broken read would otherwise look
// exactly like a quiet cluster, on every vCenter, for ever.
func TestSimTasksAreReadable(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
if err := snapshotNow(s, r.ref, "leaves-a-task", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
var fresh mo.VirtualMachine
if err := object.NewVirtualMachine(s.client.Client, r.ref).
Properties(s.ctx, r.ref, []string{"recentTask"}, &fresh); err != nil {
t.Fatalf("cannot read recentTask: %v", err)
}
// A re-read machine has to know which machine it is: runningTasks keys what
// it finds by the machine's own reference, and refreshRow looks its answer
// up by the reference of the row. A property read that did not fill that in
// would leave the task column empty on exactly the row that was just acted
// on, and nowhere else.
if fresh.Reference() != r.ref {
t.Fatalf("a re-read machine came back as %v, not %v", fresh.Reference(), r.ref)
}
if len(fresh.RecentTask) == 0 {
t.Skip("this vCenter keeps no recent tasks on the machine")
}
tasks, err := s.tasks(fresh.RecentTask)
if err != nil {
t.Fatalf("the task references could not be read: %v", err)
}
if len(tasks) == 0 {
t.Fatal("no task came back for a machine that has just had one")
}
for _, task := range tasks {
if task.Info.DescriptionId == "" {
t.Error("a task came back with nothing to call it")
}
}
// And a task that has finished is not something being done to the machine.
if busy := runningTasks(s, []mo.VirtualMachine{fresh}); len(busy) != 0 {
t.Errorf("a finished snapshot is still reported as going on: %v", busy)
}
}
// The issues listing is the one that belongs in cron, so with nothing to report
// it prints nothing at all — cron mails whatever a command prints, and a daily
// "nothing wrong" is a daily mail nobody reads. On a terminal it says so.
func TestSimIssuesListingIsQuietUnderCron(t *testing.T) {
quiet(t)
vc, model := simVCenterModel(t)
// A healthy inventory: the simulated machines are only in the issues list
// because they run without VMware Tools, so switching Tools on for all of
// them leaves nothing to report.
found, err := gatherVMs([]VCenter{vc})
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
for _, r := range found.rows {
startTools(t, model, r.ref)
}
closeSessions(found.sessions)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
if out != "" {
t.Errorf("a clean estate printed this into a pipe:\n%s", out)
}
onATerminal(t)
out = captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
if !strings.Contains(stripEscapes(out), "nothing to report") {
t.Errorf("on a terminal a clean estate printed %q", out)
}
}
+255
View File
@@ -0,0 +1,255 @@
// snapold.go — the snapshots nobody has come back for.
//
// This is the one recurring job in a vSphere estate that nothing in vCenter
// does for you: somebody takes a snapshot before an upgrade, the upgrade goes
// well, and the snapshot stays. Six weeks later its delta disk is bigger than
// the machine and the datastore is the thing that pages you.
//
// So the report reads every vCenter at once and prints one line per snapshot
// older than the age asked for, oldest first, with what it costs — and with -m
// it goes out by mail, which is the form it is actually useful in: this is a
// cron job, not something anyone remembers to run.
package main
import (
"sort"
"strings"
"github.com/fatih/color"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var snapOldColumns = []printColumn{
{header: "MACHINE", width: 24},
{header: "VC", width: 4},
{header: "SNAPSHOT", width: 22},
{header: "AGE", width: 6, right: true},
{header: "TAKEN", width: 16},
{header: "SIZE", width: 9, right: true},
}
// oldSnap is one snapshot in the report, with the machine it belongs to.
type oldSnap struct {
row vmRow
snap snapEntry
bytes int64 // what it owns on the datastore, 0 when that could not be read
}
// snapOldReport prints the report and, when asked, mails it.
func snapOldReport(cfg Config, targets []VCenter, days int, mail bool) error {
if mail {
// Asked before the sweep, not after it: finding out that the mail cannot
// be sent is of no use once the report has scrolled past.
if err := cfg.mailReady(); err != nil {
return err
}
}
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the report, where it will not be scrolled past
}
if err != nil {
return err
}
// Which machines have snapshots at all decides what the sizes are asked
// for, so it is worked out before anything else is read.
var carrying []vmRow
for _, r := range found.rows {
if len(r.snaps) > 0 {
carrying = append(carrying, r)
}
}
sizes := snapshotSizes(carrying)
var old []oldSnap
for _, r := range carrying {
for _, e := range r.snaps {
if e.days() < days {
continue
}
old = append(old, oldSnap{row: r, snap: e, bytes: sizes[snapKey(r, e)]})
}
}
// Oldest first: that is the order the work is done in, and the first line of
// a mail is the one that gets read.
sort.SliceStable(old, func(i, j int) bool {
return old[i].snap.when.Before(old[j].snap.when)
})
body := snapOldTable(old, days, len(found.rows))
if !mail || len(old) == 0 {
return nil
}
return sendmail(cfg, SF("old snapshots (%s)", vcNames(targets)), "<pre>"+body+"</pre>")
}
// snapOldTable prints the report and returns the same report as text.
//
// Both come out of one set of cells: the screen gets them painted and fitted,
// the mail gets them plain. A report that was formatted twice would eventually
// say two different things, and the mail is the copy nobody checks.
func snapOldTable(old []oldSnap, days, machines int) string {
if len(old) == 0 {
line := SF("no snapshot on any of the %d machines is %s old", machines, plural(days, "day"))
// Only where somebody is reading. This report runs weekly out of cron,
// and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everyone to filter the report away.
if !color.NoColor {
P(Cgb(line))
}
return line + "\n"
}
cells := make([][]cell, 0, len(old))
total := int64(0)
for _, o := range old {
total += o.bytes
cells = append(cells, []cell{
{o.row.name, colName},
{o.row.vc.Name, colWhere},
{o.snap.name, colChosen},
{SF("%dd", o.snap.days()), ageColor(o.snap.days())},
{o.snap.created, colAddress},
sizeCell(o.bytes),
})
}
cols := append([]printColumn(nil), snapOldColumns...)
widen(cols, cells)
head := SF("%d snapshots older than %s, on %s:", len(old), plural(days, "day"),
plural(countMachines(old), "machine"))
tail := SF("%s in %s, on %s", units.ByteSize(total),
plural(len(old), "snapshot"), plural(countMachines(old), "machine"))
P(Cwb(head))
P()
printRow(cols, "", nil)
for _, c := range cells {
printRow(cols, "", c)
}
P()
P(Cob(tail))
var sb strings.Builder
sb.WriteString(head + "\n\n")
sb.WriteString(plainRow(cols, nil) + "\n")
for _, c := range cells {
sb.WriteString(plainRow(cols, c) + "\n")
}
sb.WriteString("\n" + tail + "\n")
return sb.String()
}
// countMachines counts the machines rather than the snapshots: three snapshots
// of one machine is one machine's worth of work.
func countMachines(old []oldSnap) int {
seen := map[string]bool{}
for _, o := range old {
seen[o.row.id()] = true
}
return len(seen)
}
// sizeCell is what the snapshot owns, or a dash where the file layout could not
// be read. Nought bytes and "not known" are different answers and a report that
// prints 0 B for the second invites somebody to remove the wrong snapshot.
func sizeCell(b int64) cell {
if b <= 0 {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// ageColor takes the table's thresholds: yellow once a snapshot has stopped
// being this week's, red once it has stopped being this month's.
func ageColor(days int) string {
switch {
case days >= snapOldDays:
return colFull
case days >= snapStaleDays:
return colBusy
}
return colSize
}
// snapKey names one snapshot of one machine across every server: two vCenters
// hand out the same references, and a machine may hold two snapshots of one
// name.
func snapKey(r vmRow, e snapEntry) string { return r.id() + "/" + e.ref.Value }
// snapshotSizes is what each snapshot owns on the datastore.
//
// The file layout is asked for only for the machines that have snapshots, and
// for all of them at once per server: it lists every file of every machine and
// is far too much to carry through the ordinary sweep.
func snapshotSizes(rows []vmRow) map[string]int64 {
bySession := map[*session][]types.ManagedObjectReference{}
rowOf := map[string]vmRow{}
for _, r := range rows {
if r.sess == nil {
continue
}
bySession[r.sess] = append(bySession[r.sess], r.ref)
rowOf[r.sess.vc.Name+"/"+r.ref.Value] = r
}
out := map[string]int64{}
for s, refs := range bySession {
var vms []mo.VirtualMachine
if err := s.objects(refs, []string{"layoutEx"}, &vms); err != nil {
continue // the report is worth having without the sizes
}
for _, vm := range vms {
r, ok := rowOf[s.vc.Name+"/"+vm.Reference().Value]
if !ok {
continue
}
for ref, size := range snapshotBytes(vm.LayoutEx) {
out[r.id()+"/"+ref.Value] = size
}
}
}
return out
}
// snapshotBytes is the size of each snapshot in one machine's file layout.
//
// A snapshot owns its state file — the .vmsn, with the memory in it — and the
// *last* link of each of its disk chains. The links in front of that one are
// the disks its ancestors froze, and the delta the machine is writing to right
// now belongs to no snapshot at all: it is in the machine's own chain, not in
// any snapshot's. Counting only the last link is therefore both the whole of
// what removing this snapshot would give back and free of double counting,
// which summing whole chains is not.
func snapshotBytes(layout *types.VirtualMachineFileLayoutEx) map[types.ManagedObjectReference]int64 {
if layout == nil {
return nil
}
size := make(map[int32]int64, len(layout.File))
for _, f := range layout.File {
size[f.Key] = f.Size
}
out := make(map[types.ManagedObjectReference]int64, len(layout.Snapshot))
for _, sl := range layout.Snapshot {
total := size[sl.DataKey]
if sl.MemoryKey >= 0 && sl.MemoryKey != sl.DataKey {
total += size[sl.MemoryKey]
}
for _, d := range sl.Disk {
if len(d.Chain) == 0 {
continue
}
for _, key := range d.Chain[len(d.Chain)-1].FileKey {
total += size[key]
}
}
out[sl.Key] = total
}
return out
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"strings"
"testing"
"github.com/fatih/color"
"github.com/vmware/govmomi/vim25/types"
)
// snapRef is a snapshot's reference, as the file layout keys its entries by.
func snapRef(v string) types.ManagedObjectReference {
return types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: v}
}
// The size of a snapshot is what removing it would give back: its own state
// file and the last link of each of its disk chains. The links in front of that
// belong to its ancestors, and counting whole chains — which is the obvious
// thing to do — reports the same delta once per descendant.
func TestSnapshotBytesCountsEachDeltaOnce(t *testing.T) {
// Two snapshots in a line. base froze file 10; after-patch froze file 11.
// Each has a state file of its own (1 and 2), and the machine is writing to
// file 12, which belongs to neither.
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Type: "snapshotData", Size: 100},
{Key: 2, Type: "snapshotData", Size: 200},
{Key: 10, Type: "diskExtent", Size: 1000},
{Key: 11, Type: "diskExtent", Size: 2000},
{Key: 12, Type: "diskExtent", Size: 4000}, // the running delta
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{
Key: snapRef("snapshot-1"), DataKey: 1, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}},
}}},
},
{
Key: snapRef("snapshot-2"), DataKey: 2, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}}, {FileKey: []int32{11}},
}}},
},
},
}
got := snapshotBytes(layout)
if got[snapRef("snapshot-1")] != 1100 {
t.Errorf("the first snapshot is %d bytes, want 1100", got[snapRef("snapshot-1")])
}
if got[snapRef("snapshot-2")] != 2200 {
t.Errorf("the second snapshot is %d bytes, want 2200 — its parent's delta was counted again",
got[snapRef("snapshot-2")])
}
}
// A separate memory file is part of the snapshot; a memoryKey of -1 means there
// is not one, and the key that says so must not be looked up as a file.
func TestSnapshotBytesTakesTheMemoryFile(t *testing.T) {
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Size: 100}, {Key: 3, Size: 8000},
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{Key: snapRef("s"), DataKey: 1, MemoryKey: 3},
},
}
if got := snapshotBytes(layout)[snapRef("s")]; got != 8100 {
t.Errorf("with a memory file the snapshot is %d bytes, want 8100", got)
}
layout.Snapshot[0].MemoryKey = -1
if got := snapshotBytes(layout)[snapRef("s")]; got != 100 {
t.Errorf("without one it is %d bytes, want 100", got)
}
}
func TestSnapshotBytesOfNothing(t *testing.T) {
if got := snapshotBytes(nil); got != nil {
t.Errorf("a machine with no file layout reported %v", got)
}
}
// Nought bytes and "the layout could not be read" are different answers, and
// the second must not look like a snapshot that costs nothing.
func TestSizeCellSaysWhenItDoesNotKnow(t *testing.T) {
if got := sizeCell(0); got.text != "-" || got.col != colOff {
t.Errorf("an unknown size is shown as %q", got.text)
}
if got := sizeCell(1 << 30); !strings.Contains(got.text, "GB") {
t.Errorf("a gigabyte is shown as %q", got.text)
}
}
// The report's ages take the table's colours, so a red count in the list and a
// red line in the mail mean the same thing.
func TestAgeColorMatchesTheTable(t *testing.T) {
for _, c := range []struct {
days int
want string
}{
{0, colSize}, {snapStaleDays - 1, colSize},
{snapStaleDays, colBusy}, {snapOldDays - 1, colBusy},
{snapOldDays, colFull}, {365, colFull},
} {
if got := ageColor(c.days); got != c.want {
t.Errorf("a snapshot of %d days is coloured wrongly", c.days)
}
}
// And the same thresholds the column uses.
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", snapOldDays+1)}
if r.snapColor() != colFull {
t.Error("the column and the report disagree about an old snapshot")
}
}
// A report with nothing in it says so in the same breath as saying what it
// looked for: "no old snapshots" without the age is not an answer.
//
// And it says it only where somebody is reading. The report runs weekly out of
// cron, and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everybody to filter the report away, and then the week it has
// something to say is filtered away with it.
func TestAnEmptyReportSaysWhatItLookedForOnlyOnATerminal(t *testing.T) {
var body string
out := captureStdout(t, func() { body = snapOldTable(nil, 30, 212) })
if out != "" {
t.Errorf("an empty report printed %q into a pipe", out)
}
if !strings.Contains(body, "212") || !strings.Contains(body, "30 days") {
t.Errorf("the empty report reads %q", body)
}
onATerminal(t)
out = captureStdout(t, func() { snapOldTable(nil, 30, 212) })
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("on a terminal the empty report printed %q", out)
}
}
// onATerminal makes the colour library — which is also gvm's answer to "is
// anybody reading this" — say yes for the length of one test.
func onATerminal(t *testing.T) {
t.Helper()
saved := color.NoColor
color.NoColor = false
t.Cleanup(func() { color.NoColor = saved })
}
// The report counts machines, not snapshots: three snapshots of one machine is
// one machine's worth of work.
func TestCountMachines(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
other := testRow("db01", true, "10.0.0.6")
other.ref = types.ManagedObjectReference{Value: "vm-43"}
old := []oldSnap{
{row: r, snap: aged("a", 40)},
{row: r, snap: aged("b", 50)},
{row: other, snap: aged("c", 60)},
}
if got := countMachines(old); got != 2 {
t.Errorf("three snapshots on two machines counted as %d machines", got)
}
}
// The mail carries the same table as the screen, and carries no escape
// sequences: a mail client shows those as four stray characters per colour.
func TestTheMailedReportHasNoColours(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
old := []oldSnap{{row: r, snap: aged("before-patch", 63), bytes: 3 << 30}}
var body string
out := captureStdout(t, func() { body = snapOldTable(old, 30, 1) })
for _, want := range []string{"web01", "before-patch", "63d"} {
if !strings.Contains(body, want) {
t.Errorf("the mail leaves out %q:\n%s", want, body)
}
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the screen leaves out %q:\n%s", want, out)
}
}
if strings.ContainsRune(body, 0x1b) {
t.Errorf("the mail carries escape sequences:\n%q", body)
}
}
+44 -8
View File
@@ -11,6 +11,8 @@
package main
import (
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/methods"
"github.com/vmware/govmomi/vim25/mo"
@@ -26,12 +28,32 @@ type snapEntry struct {
ref types.ManagedObjectReference
name string
desc string
created string
created string // when it was taken, as it is shown
when time.Time // and as it is compared: an age is not a string
depth int
prefix string // the branch drawn in front of the name
current bool // the state the machine is running from
}
// age is how long ago the snapshot was taken. A snapshot whose date did not
// come back has no age rather than an age of nothing: zero would read as
// "taken just now", which is the opposite of what an absent date means.
func (e snapEntry) age() (time.Duration, bool) {
if e.when.IsZero() {
return 0, false
}
return time.Since(e.when), true
}
// days is the age in whole days, for the reports that count in them.
func (e snapEntry) days() int {
d, ok := e.age()
if !ok {
return 0
}
return int(d.Hours() / 24)
}
// line is the entry as it is shown: its branch, its name, when it was taken, and
// a mark when it is the one the machine is running from.
func (e snapEntry) line() string {
@@ -75,6 +97,7 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
name: n.Name,
desc: n.Description,
created: n.CreateTime.Local().Format("02.01.2006 15:04"),
when: n.CreateTime,
depth: depth,
prefix: prefix + branch,
current: n.Snapshot == current,
@@ -88,6 +111,12 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
// snapshotsOf reads the machine's snapshots as a flat list, parents before their
// children. Empty when it has none.
//
// The sweep brings the same tree back for every machine at once (browse.go), and
// this asks for one machine's again. That is deliberate: everything that acts on
// a snapshot addresses it by reference, and a reference out of a sweep that ran
// minutes ago may name a snapshot somebody has since removed. The table may be a
// few minutes old; the list one is about to revert to may not be.
func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, error) {
vm := object.NewVirtualMachine(s.client.Client, ref)
@@ -95,15 +124,22 @@ func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, err
if err := vm.Properties(s.ctx, ref, []string{"snapshot"}, &mvm); err != nil {
return nil, errf("%s: cannot read the snapshots: %w", s.vc.Name, err)
}
if mvm.Snapshot == nil {
return nil, nil
}
return snapshotsIn(mvm.Snapshot), nil
}
current := types.ManagedObjectReference{}
if mvm.Snapshot.CurrentSnapshot != nil {
current = *mvm.Snapshot.CurrentSnapshot
// snapshotsIn is the tree as it comes out of the property collector, flattened.
// One function for both ways of getting there — the sheet asking for one machine
// and the sweep bringing back every machine — so the two cannot disagree about
// what a machine's snapshots are.
func snapshotsIn(info *types.VirtualMachineSnapshotInfo) []snapEntry {
if info == nil {
return nil
}
return flattenSnapshots(mvm.Snapshot.RootSnapshotList, current), nil
current := types.ManagedObjectReference{}
if info.CurrentSnapshot != nil {
current = *info.CurrentSnapshot
}
return flattenSnapshots(info.RootSnapshotList, current)
}
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
+118 -12
View File
@@ -21,10 +21,17 @@ type sortOrder struct {
name string // what it is called, in the title and the legend
natural bool // its own direction: true means largest or busiest first
cmp func(a, b vmRow) int
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// sortOrders in the order the legend lists them: the two that identify a machine
// first, then what it is doing, then what it is made of, then where it lives.
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
var sortOrders = []sortOrder{
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
{key: 'p', name: "power", natural: true,
@@ -33,7 +40,29 @@ var sortOrders = []sortOrder{
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
{key: 'm', name: "memory in use", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
{key: 's', name: "memory size", natural: true,
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
// By what is wrong with the machine, worst first: broken above wants-a-look
// above nothing to report, and within each the machine with the most to
// answer for first. Sorting the reasons as text would put "alarm" above
// "disks need consolidating" and mean nothing at all.
{key: 'w', name: "issues", natural: true,
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
{key: 's', name: "memory size", natural: true, legendBreak: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
}},
@@ -105,6 +134,30 @@ func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
return 0
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// cmpAddress orders by address, unknown highest — which puts it last under the
// a-to-z direction this order is asked for with.
func cmpAddress(a, b vmRow) int {
@@ -180,22 +233,55 @@ func (b *browser) sortLabel() string {
return arrow + " " + b.order().name
}
// sortLegend is the one line offering the choices. Short by necessity — it shares
// the status line — and the title says what the order is anyway, so nobody who
// misses it is lost.
func sortLegend() string {
parts := make([]string, 0, len(sortOrders)+1)
// sortLegend is the choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives.
//
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
parts = append(parts, string(o.key)+"·"+shortName(o.name))
entries = append(entries, string(o.key)+"·"+shortName(o.name))
}
// Reverse is not an order of its own and goes at the end.
entries = append(entries, string(sortReverse)+"·reverse")
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
return []string{one}
}
// Two, broken where the meaning breaks. The second line is indented under
// the first one's entries rather than under its label, so the two read as
// one list and not as a sentence continued.
at := len(sortOrders)
for i, o := range sortOrders {
if o.legendBreak {
at = i
break
}
}
return []string{
label + strings.Join(entries[:at], " "),
SR(" ", len(label)) + strings.Join(entries[at:], " "),
}
parts = append(parts, string(sortReverse)+"·reverse")
return "sort: " + strings.Join(parts, " ")
}
// shortName is the legend's spelling: the title has room for the whole name, one
// line shared with the status does not.
func shortName(name string) string {
switch name {
case "power":
return "pwr" // as the column is headed, and it keeps the legend inside 80
case "cpu load":
return "cpu%"
case "memory in use":
@@ -208,6 +294,12 @@ func shortName(name string) string {
return "vc"
case "address":
return "ip"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
return name
}
@@ -216,7 +308,17 @@ func shortName(name string) string {
// choice leaves the order alone: this is the one prompt in the list that is
// reached by accident, and doing nothing is the right answer to a stray key.
func (b *browser) sortPrompt() {
b.prompt = &prompt{text: sortLegend(), col: colValue} // a menu, not a warning
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -263,6 +365,10 @@ func (b *browser) sortedColumn(header string) bool {
return header == "HOST"
case "address":
return header == "IP"
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}
+149 -6
View File
@@ -204,8 +204,11 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
}
// In the table the order belongs to: sorting by what is wrong with a
// machine lights the reason column, which only the issues listing has.
table := listColumns(b.rows, o.name == "issues")
lit := 0
for _, c := range browseColumns {
for _, c := range table {
if b.sortedColumn(c.header) {
lit++
}
@@ -218,17 +221,59 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
legend := sortLegend()
lines := sortLegend(80)
legend := strings.Join(lines, "\n")
for _, o := range sortOrders {
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the legend does not offer %q for %s: %s", string(o.key), o.name, legend)
t.Errorf("the legend does not offer %q for %s:\n%s", string(o.key), o.name, legend)
}
}
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
t.Errorf("the legend does not offer the reverse: %s", legend)
t.Errorf("the legend does not offer the reverse:\n%s", legend)
}
if n := len([]rune(legend)); n > 78 { // a terminal of eighty, less the gutter
t.Errorf("the legend is %d columns wide: %s", n, legend)
// Every line of it fits a terminal of eighty. The legend has the bottom two
// rows to itself, and they begin at the left edge rather than behind the
// pointer's gutter, so the budget is eighty whole — but a line over it would
// be truncated, and the choices it hid would be unreachable in the only
// place they are offered.
for i, line := range lines {
if n := len([]rune(line)); n > 80 {
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
}
}
// Two rows, and not three: there are only two to spare.
if len(lines) > 2 {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// One line wherever one line will do — which is every terminal wide enough
// for it, and the usual case. A legend on two lines is what a narrow
// terminal gets instead of one that runs off the edge.
wide := sortLegend(200)
if len(wide) != 1 {
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
}
if n := len([]rune(wide[0])); n > 200 {
t.Errorf("the one-line legend is %d columns wide", n)
}
// And every choice is on it, so nothing is reachable only when the terminal
// happens to be narrow.
for _, o := range sortOrders {
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
}
}
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
}
// The width at which it gives up on one line is the width of the legend
// itself, and not a number written down somewhere.
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
t.Error("the legend broke in two at exactly its own width")
}
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
t.Error("the legend stayed on one line one column too narrow for it")
}
// Distinct letters, or one of them would be unreachable.
@@ -285,3 +330,101 @@ func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
}
}
}
// By how many snapshots a machine is carrying. Nought is a figure here and not
// a missing one — nothing to clean up is a fact about the machine — so it sorts
// where nought belongs: at the bottom going down, at the top coming back up.
func TestSortBySnapshotCount(t *testing.T) {
rows := []vmRow{
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
}
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, 'z', true); got != "three one none" {
t.Errorf("most snapshots first gave %q", got)
}
if got := orderOf(t, rows, 'z', false); got != "none one three" {
t.Errorf("fewest first gave %q", got)
}
}
// By what is wrong with the machine: broken above wants-a-look above nothing to
// report, and within each the machine with the most to answer for first.
func TestSortByIssues(t *testing.T) {
rows := []vmRow{
sortRow("fine", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("warned", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("broken", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
sortRow("worse", "v308", "esx1", "10.0.0.4", 1, 1024, 0, 0, true),
}
// sortRow builds machines with no guest information at all, which reports
// nothing: the issues that are only true of a running machine need a guest
// to be true of. So each is given exactly what it is named for.
for i := range rows {
rows[i].vm.Guest = &types.GuestInfo{
ToolsRunningStatus: "guestToolsRunning",
IpAddress: rows[i].ip(),
}
}
rows[1].vm.Summary.OverallStatus = types.ManagedEntityStatusYellow // one warning
rows[2].vm.Summary.Runtime.ConsolidationNeeded = true // one breakage
rows[3].vm.Summary.Runtime.ConsolidationNeeded = true // and the same
rows[3].vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // plus a warning
if got := orderOf(t, rows, 'w', true); got != "worse broken warned fine" {
t.Errorf("worst first gave %q", got)
}
// And the other way up, the machines with nothing wrong come first, which
// is a listing worth having too.
if got := orderOf(t, rows, 'w', false); got != "fine warned broken worse" {
t.Errorf("nothing to report first gave %q", got)
}
}
// orderOf sorts the rows given by one order and returns the names in order.
//
// A key that is not an order at all is fatal here rather than left to sort by
// name: the browser's default order is index nought, so a test naming a letter
// that has been renamed would go on passing while checking the name order.
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
t.Helper()
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
found := false
for i, o := range sortOrders {
if o.key == key {
b.sortBy, found = i, true
}
}
if !found {
t.Fatalf("%q is not one of the sort orders", string(key))
}
b.applySort()
var names []string
for _, r := range b.rows {
names = append(names, r.name)
}
return strings.Join(names, " ")
}
// The two snapshot orders are different questions: how many, and how old. A
// machine with one snapshot from March needs attention before one with six
// from this morning.
func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
rows := []vmRow{
sortRow("many-new", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("one-ancient", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
}
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
rows[1].snaps = []snapEntry{aged("march", 200)}
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
t.Errorf("by count: %q", got)
}
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
t.Errorf("by age: %q", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
// tasks.go — what a machine is in the middle of.
//
// A machine being cloned, migrated or consolidated looks in the table exactly
// like one that is idle, and that is the one moment when the table is wrong
// about the most important thing on the line: why the machine is slow, why its
// disk is growing, why it must be left alone. vCenter keeps the answer on the
// machine itself, in recentTask, so the sweep picks it up along the way.
//
// "Recent" is vCenter's word, not gvm's: a task stays on that list for minutes
// after it has finished. Only the ones that are still going on are shown — a
// finished task is history, and the event log is where history belongs.
package main
import (
"strings"
"time"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// runningTask is one thing vCenter is doing to a machine right now.
type runningTask struct {
what string // the operation, in one word
queued bool // accepted but not started yet
progress int32
since time.Time
}
// taskVerbs are the operations worth naming in the eight characters the column
// has left once a percentage is beside them — "pwr off" for the same reason the
// sort legend says pwr. The key is
// vSphere's descriptionId, which is the same word on every vCenter — the
// task's own Description is localised, so a German vCenter would put German
// into an English table.
//
// Anything not listed keeps its method name, which is still the truth and still
// tells an operator to leave the machine alone.
var taskVerbs = map[string]string{
"createSnapshot": "snapshot",
"removeSnapshot": "rm snap",
"removeAllSnapshots": "rm snaps",
"revertToSnapshot": "revert",
"consolidateDisks": "consolid",
"promoteDisks": "consolid",
"clone": "clone",
"relocate": "migrate",
"migrate": "migrate",
"reconfigure": "reconfig",
"powerOn": "pwr on",
"powerOff": "pwr off",
"suspend": "suspend",
"reset": "reset",
"shutdownGuest": "shutdown",
"rebootGuest": "reboot",
"destroy": "delete",
"customize": "custom",
"createDisk": "disk",
"extendDisk": "disk",
"upgradeTools": "tools",
"upgradeVirtualHardware": "hardware",
}
// taskVerb is the operation in one word. A descriptionId reads
// "VirtualMachine.createSnapshot"; the kind in front of the dot is already the
// row the task is on, so only what follows it says anything.
func taskVerb(descriptionID string) string {
method := descriptionID
if i := strings.LastIndexByte(method, '.'); i >= 0 {
method = method[i+1:]
}
if v, ok := taskVerbs[method]; ok {
return v
}
if method == "" {
return "busy"
}
return method
}
// cell is the task as the table shows it: what it is and how far it has got.
// A percentage is only shown once there is one — vCenter reports 0 both for
// "just started" and for "no idea", and a task sitting at 0 % looks stuck when
// it is merely young.
func (t runningTask) cell() string {
if t.queued {
return t.what + " q"
}
if t.progress > 0 {
return SF("%s %d%%", t.what, t.progress)
}
return t.what
}
// line is the task on the machine's sheet, where there is room to say when it
// started and that queued means nothing has happened yet.
func (t runningTask) line() string {
parts := []string{t.what}
switch {
case t.queued:
parts = append(parts, "queued, not started")
case t.progress > 0:
parts = append(parts, SF("%d %%", t.progress))
}
if !t.since.IsZero() {
parts = append(parts, "since "+t.since.Local().Format("15:04:05"))
}
return join(parts)
}
// runningTasks maps the machines of one sweep to what is being done to them.
// The task references come off the machines themselves and are read in one
// call for the whole inventory, so this costs one round trip per vCenter no
// matter how much is going on.
//
// A machine with two tasks at once keeps the one that has started; of two
// running ones, the first. There is one column, and "something is going on" is
// what it has to say.
func runningTasks(s *session, vms []mo.VirtualMachine) map[types.ManagedObjectReference]runningTask {
var refs []types.ManagedObjectReference
for _, vm := range vms {
refs = append(refs, vm.RecentTask...)
}
if len(refs) == 0 {
return nil
}
tasks, err := s.tasks(refs)
if err != nil {
return nil // the table is worth having without it
}
byRef := make(map[types.ManagedObjectReference]runningTask, len(tasks))
for _, t := range tasks {
rt, ok := taskOf(t.Info)
if !ok {
continue
}
byRef[t.Reference()] = rt
}
out := make(map[types.ManagedObjectReference]runningTask, len(vms))
for _, vm := range vms {
for _, ref := range vm.RecentTask {
rt, ok := byRef[ref]
if !ok {
continue
}
if cur, seen := out[vm.Reference()]; seen && (!cur.queued || rt.queued) {
continue
}
out[vm.Reference()] = rt
}
}
return out
}
// taskOf is one task, when it is still going on.
func taskOf(info types.TaskInfo) (runningTask, bool) {
switch info.State {
case types.TaskInfoStateRunning:
since := info.QueueTime
if info.StartTime != nil {
since = *info.StartTime
}
return runningTask{what: taskVerb(info.DescriptionId), progress: info.Progress, since: since}, true
case types.TaskInfoStateQueued:
return runningTask{what: taskVerb(info.DescriptionId), queued: true, since: info.QueueTime}, true
}
return runningTask{}, false
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The operation is named from vSphere's descriptionId and not from the task's
// own Description, which vCenter localises: a German vCenter would otherwise
// put German words in an English table.
func TestTaskVerb(t *testing.T) {
for _, c := range []struct{ id, want string }{
{"VirtualMachine.createSnapshot", "snapshot"},
{"VirtualMachine.removeAllSnapshots", "rm snaps"},
{"VirtualMachine.relocate", "migrate"},
{"VirtualMachine.reconfigure", "reconfig"},
{"Datacenter.somethingNobodyHasHeardOf", "somethingNobodyHasHeardOf"},
{"noDotAtAll", "noDotAtAll"},
{"", "busy"},
} {
if got := taskVerb(c.id); got != c.want {
t.Errorf("taskVerb(%q) = %q, want %q", c.id, got, c.want)
}
}
}
// Every verb fits the column, or the cell it is put in would be truncated with
// the percentage — the part that says whether anything is happening — cut off.
func TestEveryTaskVerbFitsItsColumn(t *testing.T) {
room := taskColumn.width - len(" 100%")
for id, verb := range taskVerbs {
if len(verb) > room {
t.Errorf("%s is called %q, which is %d characters of the %d there are",
id, verb, len(verb), room)
}
}
}
// A task at nought per cent has not reported any progress, which is not the
// same as having made none: printing 0 % makes a task that has just started
// look stuck.
func TestTaskCell(t *testing.T) {
for _, c := range []struct {
task runningTask
want string
}{
{runningTask{what: "clone", progress: 40}, "clone 40%"},
{runningTask{what: "clone"}, "clone"},
{runningTask{what: "clone", queued: true}, "clone q"},
{runningTask{what: "clone", queued: true, progress: 10}, "clone q"},
} {
if got := c.task.cell(); got != c.want {
t.Errorf("cell() = %q, want %q", got, c.want)
}
}
}
func TestTaskLineSpellsQueuedOut(t *testing.T) {
since := time.Date(2026, 9, 8, 11, 42, 0, 0, time.Local)
got := runningTask{what: "consolid", queued: true, since: since}.line()
if got != "consolid · queued, not started · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
got = runningTask{what: "clone", progress: 40, since: since}.line()
if got != "clone · 40 % · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
}
// Only what is still going on is a task. A task that has finished stays on
// vCenter's recentTask list for minutes afterwards, and a table that showed it
// would report a snapshot being taken long after it was taken.
func TestOnlyRunningTasksCount(t *testing.T) {
for _, c := range []struct {
state types.TaskInfoState
want bool
}{
{types.TaskInfoStateRunning, true},
{types.TaskInfoStateQueued, true},
{types.TaskInfoStateSuccess, false},
{types.TaskInfoStateError, false},
} {
_, ok := taskOf(types.TaskInfo{State: c.state, DescriptionId: "VirtualMachine.clone"})
if ok != c.want {
t.Errorf("a %s task counts = %v, want %v", c.state, ok, c.want)
}
}
}
// A running task is timed from when it started, a queued one from when it was
// accepted — there is nothing else to time it from.
func TestTaskTakesItsTimeFromTheRightEnd(t *testing.T) {
queued := time.Date(2026, 9, 8, 11, 0, 0, 0, time.UTC)
started := time.Date(2026, 9, 8, 11, 5, 0, 0, time.UTC)
got, _ := taskOf(types.TaskInfo{
State: types.TaskInfoStateRunning, QueueTime: queued, StartTime: &started,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(started) {
t.Errorf("a running task is timed from %v", got.since)
}
got, _ = taskOf(types.TaskInfo{
State: types.TaskInfoStateQueued, QueueTime: queued,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(queued) {
t.Errorf("a queued task is timed from %v", got.since)
}
}
+12
View File
@@ -49,6 +49,18 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
}
}
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
tmp := ""
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ret := make([]byte, n)
+6
View File
@@ -86,6 +86,7 @@ const (
keyCtrlO
keyCtrlR
keyCtrlS
keyCtrlW
keyCtrlC
keyEsc
)
@@ -143,6 +144,11 @@ func (kr *keyReader) next() key {
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':
+84 -4
View File
@@ -15,8 +15,10 @@ import (
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/property"
"github.com/vmware/govmomi/view"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
@@ -30,6 +32,12 @@ type session struct {
ctx context.Context
client *govmomi.Client
cancel context.CancelFunc
// The names of the alarm definitions this server has triggered, filled in by
// the sweep when anything is actually alarming (browse.go). It belongs to
// the connection rather than to a machine: one alarm stands against many
// machines, and its name is worth reading once per server, not once per row.
alarms map[types.ManagedObjectReference]string
}
// connect logs in to one server. The caller closes what comes back — a session
@@ -38,12 +46,11 @@ type session struct {
func connect(vc VCenter) (*session, error) {
ctx, cancel := context.WithCancel(context.Background())
u, err := url.Parse(vc.sdkURL())
u, err := loginURL(vc)
if err != nil {
cancel()
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
return nil, err
}
u.User = url.UserPassword(vc.User, vc.Password)
dial, dialCancel := context.WithTimeout(ctx, dialTimeout)
defer dialCancel()
@@ -56,6 +63,24 @@ func connect(vc VCenter) (*session, error) {
return &session{vc: vc, ctx: ctx, client: client, cancel: cancel}, nil
}
// loginURL is the endpoint with the credentials in it — the one place where a
// password is opened and handed over. Its own function so that what goes on the
// wire can be checked without a server: whether the password that leaves here is
// the opened one, and not the sealed word out of the file, is the whole claim of
// seal.go.
func loginURL(vc VCenter) (*url.URL, error) {
u, err := url.Parse(vc.sdkURL())
if err != nil {
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
}
secret, err := vc.password()
if err != nil {
return nil, err
}
u.User = url.UserPassword(vc.User, secret)
return u, nil
}
// close logs out and drops the context. Logging out is best effort: there is
// nothing useful to do about a failure while shutting down.
func (s *session) close() {
@@ -105,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
return nil
}
// vms and hosts are the two inventory sweeps gvm makes.
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
// for everything of that kind in the whole inventory.
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
var out []mo.VirtualMachine
return out, s.retrieve("VirtualMachine", props, &out)
@@ -115,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
var out []mo.HostSystem
return out, s.retrieve("HostSystem", props, &out)
}
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
var out []mo.Datastore
return out, s.retrieve("Datastore", props, &out)
}
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
// vSphere client puts in the URLs of the objects it shows, which is the one
// thing gvm cannot work out from the configuration alone (see vsphereURL).
func (s *session) instanceUUID() string {
if s.client == nil {
return ""
}
return s.client.ServiceContent.About.InstanceUuid
}
// objects fills dst with the named properties of exactly the objects given,
// rather than of everything of a kind. Alarm definitions and tasks are not in
// the inventory container view — they hang off their managers — so the only way
// to read them is by reference, and by all of them in one call: one round trip
// for a screenful, not one per line.
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
if len(refs) == 0 {
return nil
}
if err := property.DefaultCollector(s.client.Client).
Retrieve(s.ctx, refs, props, dst); err != nil {
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
}
return nil
}
// tasks reads what those task references are doing. A task that has finished is
// still on a machine's recentTask list for a while afterwards, so the caller
// decides what counts as going on; this only reports.
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
var out []mo.Task
return out, s.objects(refs, []string{"info"}, &out)
}
// alarmNames resolves alarm definitions to the names a person gave them. A
// triggered alarm carries only the reference of its definition, and "alarm-3 is
// red" is not something anyone can act on.
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
var alarms []mo.Alarm
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
return nil // the names are a courtesy; the references still say which
}
out := make(map[types.ManagedObjectReference]string, len(alarms))
for _, a := range alarms {
out[a.Reference()] = a.Info.Name
}
return out
}
+1 -1
View File
@@ -1 +1 @@
1.0.17
1.1.7
+47 -12
View File
@@ -14,22 +14,40 @@ import (
"github.com/fatih/color"
)
// lsOptions is what one printed listing was asked for. A struct rather than six
// arguments in a row: three of them are booleans, and a call site reading
// (rows, "", false, true, false) says nothing about which is which.
type lsOptions struct {
match string // regexp on the machine's name
orderBy string // one of sortOrders, by letter or by name
reverse bool
issues bool // only the machines with something wrong with them
json bool // as a document instead of a table
}
// lsvm prints the machines of every server it is given. One unreachable vCenter
// is a line of complaint, not the end of the listing.
func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
re, err := regexp.Compile("(?i)" + match)
func lsvm(targets []VCenter, opt lsOptions) error {
re, err := regexp.Compile("(?i)" + opt.match)
if err != nil {
return errf("bad pattern %q: %w", match, err)
return errf("bad pattern %q: %w", opt.match, err)
}
by, err := findOrder(orderBy)
by, err := findOrder(opt.orderBy)
if err != nil {
return err
}
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the table, where it will not be scrolled past
// In a table the failures are said before it, where they will not be
// scrolled past. In a document they belong *in* it: a line of prose in the
// middle of the JSON would break whatever is reading it, and a script that
// cannot tell "no machines" from "the server did not answer" is a script
// that reports an empty cluster.
if !opt.json {
for _, why := range found.failed {
PE(why)
}
}
if err != nil {
return err
@@ -41,9 +59,26 @@ func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
rows = append(rows, r)
}
}
sortRows(rows, by, reverse != sortOrders[by].natural)
if opt.issues {
rows = withIssues(rows)
}
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
printList(rows)
if opt.json {
return printJSON(found, rows)
}
// Nothing to report, and nobody watching: say nothing at all. This is the
// listing that belongs in cron, and cron mails whatever a command prints —
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
// one morning it did not arrive would mean nothing either. On a terminal
// somebody is waiting for an answer, so there it is said.
if opt.issues && len(rows) == 0 {
if !color.NoColor {
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
}
return nil
}
printList(rows, listColumns(rows, opt.issues))
return nil
}
@@ -54,8 +89,8 @@ func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
// machine names off at twenty-two characters — silently, with an ellipsis, into
// output whose whole purpose is to be read by something else.
func printList(rows []vmRow) {
cols := fitColumns(printWidth())
func printList(rows []vmRow, table []browseColumn) {
cols := fitColumnsOf(table, printWidth(table))
pcs := make([]printColumn, len(cols))
for i, c := range cols {
@@ -87,9 +122,9 @@ func printList(rows []vmRow) {
// printWidth is what the table is laid out for. color.NoColor is the answer to
// "is this a terminal" that the colours already go by, so the two cannot disagree
// about where the output is headed.
func printWidth() int {
func printWidth(table []browseColumn) int {
if color.NoColor {
return tableWidth(browseColumns)
return tableWidth(table)
}
cols, _ := termSize()
return max(cols, 20)