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
Michael Wesemann 9b0d218174 [mike@mwxm4] 2026-09-07 11:37:41 +02:00
Michael Wesemann 1c14902d7c [mike@mwxm4] 2026-09-06 14:11:46 +02:00
Michael Wesemann 321c69ebaa [mike@mwxm4] 2026-09-06 13:27:00 +02:00
Michael Wesemann 0558e42f2b [mike@mwxm4] 2026-09-06 12:55:06 +02:00
Michael Wesemann 5335e585d2 [mike@mwxm4] 2026-09-06 12:41:17 +02:00
42 changed files with 9050 additions and 788 deletions
+513 -25
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,23 +36,59 @@ template there and says so — fill in the passwords and it works.
vcenter.v308.insecure = true
`-v <name>` picks a server by its full name; an unknown name is an error rather
than a silent fallback to the first one in the list. Every setting has an
environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
than a silent fallback to the first one in the list. The commands that sweep
every server — `gvm`, `gvm vm -l`, `gvm snap --old` — also take a list,
`-v v308,v108`, in the order given and with a repeat counted once; the commands
that act on one machine refuse a list rather than taking the first of it. Every
setting has an environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
without the password living in a file.
The file holds passwords, so gvm creates it mode 0600 and complains when it
finds it readable by others.
### Passwords in the file
A password written into `~/.gvmrc` in the clear is sealed on the next run of gvm
and replaced in place by a `gvmenc1:...` word:
vcenter.v308.password = gvmenc1:otspj7CLz1/8vEUFHpfrKH/zKiVLqlOvVwQ…
Nothing else about the file changes — the keys, the spacing, the order, the blank
lines and the comments beside a setting are all left exactly as they were — and
gvm says which password it sealed. Only the value is sealed, never the file, so
`~/.gvmrc` stays readable and editable by hand.
`gvm config -p v308` asks for a password instead of taking it from the file and
writes it sealed straight away. That is the way to set one: a password typed into
the file stands there in the clear until the next run of gvm, and by then it has
been through the editor's swap file and whatever backs the home directory up.
`gvm config` says whether each password is sealed, still in the clear, or sealed
but no longer openable — it opens each one and throws it away, because "it is
sealed" is worth nothing if it does not open.
**What this is, and is not.** The key is compiled into gvm and is the same in
every copy of it, so whoever holds `~/.gvmrc` *and* a gvm binary can open the
value; prising the key out is an afternoon's work, not a cluster's. This is not a
vault. What it buys is that the password no longer stands in the clear in a
backup, in a home directory that syncs somewhere, in an editor's swap file, or on
a screen someone else is looking at. The 0600 is what keeps other local users
out. A value given in `GVM_VCENTER_*_PASSWORD` is taken as it stands, sealed or
not.
## Commands
| command | what it does |
| --- | --- |
| *(nothing)* | browse the machines interactively (see below) |
| `vm` | the same, spelled out |
| `vm -l [-m <re>]` | print them instead; all vCenters unless `-v` names one |
| `vm -l [-m <re>] [--sort <order>] [--reverse]` | print them instead; all vCenters unless `-v` names one |
| `vm -l --issues` | only the machines with something wrong with them |
| `vm -l --json` | the same listing as a JSON document |
| `snap -l <vm>` | list a machine's snapshots |
| `snap -n <vm>` | take a snapshot, name printed |
| `snap --old [-d <days>] [-m]` | every snapshot older than that, on every vCenter, optionally by mail |
| `snap -r <vm> -s <snap>` | remove one snapshot |
| `snap --revert <vm> -s <snap>` | put the machine back to that snapshot |
| `snap --removeall <vm>` | remove all of them |
@@ -58,8 +99,11 @@ finds it readable by others.
| `power --reset <vm>` | reset at the hypervisor — hard |
| `host [-t]` | per-host cpu, memory, machine counts; `-t` also posts them |
| `host -c` | just the machine counts |
| `ds [-t]` | per-datastore capacity, free space, over-commitment; `-t` also posts them |
| `log -l [-m] [-t <min>]` | the event log, optionally by mail, default 60 minutes |
| `config` | the effective configuration, passwords not shown |
| `config -p <vcenter>` | ask for a password and store it sealed |
| `completion zsh\|bash` | print the shell completion script |
## The interactive list
@@ -72,14 +116,170 @@ 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
24-row terminal whole, and the identity numbers and the annotation at the bottom
are the only part anyone scrolls for. A value too long for the width is carried
onto a continuation line under its own label rather than cut off at the edge,
including one long word such as a datastore path, so a narrow terminal loses
nothing. The machine's name, vCenter, datacenter and host are the title.
^o sort the table (see below)
^w only the machines with something wrong with them (see below)
^r ask the servers again
esc clear the filter, or leave when there is none
^c leave
The columns are name, vCenter, power, snapshots, address, host, vCPUs, CPU load,
memory and memory in use, and the guest's operating system. The two load figures
are percentages of what the machine is allowed to use and of what it has
configured; a machine that is not running has no load rather than a load of zero
and shows a dash. Past 75 % they turn yellow, past 90 % red.
The snapshot column is how many the machine is dragging along, aged by colour:
past a week the count turns yellow, past a month red — a month being also what
`snap --old` reports on, so a red count means "this machine is in that report".
A machine with none shows a dash. The count is what the column says and the age
is only how it is said, which is why the sheet spells the date out: a colour
cannot be read in a pipe.
Two columns are not always there, because they hold an exception rather than a
property, and thirteen characters of blank down two hundred rows is thirteen
characters spent on nothing:
* **TASK** appears while vCenter is doing something to any machine in the list —
a clone, a migration, a consolidation, with its progress — and is gone again
when nothing is going on. A column that turns up because somebody started a
clone is not the layout shifting about: it is the news.
* **WHY** takes the guest operating system's place in the issues list (`^w`,
`--issues`), where every row has a reason to be there — and the four figures
go with it, so that the reason has the width. It is the last column that
listing gives up rather than the first: in a list whose every row is there
because of it, dropping the reason first leaves a list of machines with no
reason showing on any of them.
A terminal too narrow for all of that gives columns up, least useful first: the
guest's operating system, then the host, then the snapshot count, then the
address, then the vCPU count — so what survives longest is what a glance is for.
Each step only ever takes a column away, never brings one back, so dragging a
window narrower does not rearrange the table. An eighty-column terminal keeps
everything but the operating system, the host and the snapshot count: the name
column's minimum is one notch narrower than it reads in order to buy the address
its place there, and the count is the one column here that has somewhere else to
be said — `^w`, `--issues` and `snap --old` all name it and date it.
### Sorting
`^o` puts a legend on the status line and the next key picks the order, so the
list stays on screen while it rearranges itself:
sort: n·name p·pwr c·cpu% m·mem% 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
whatever is current. Anything that is not a choice — Esc, a stray letter — leaves
the table as it was.
The title says which order the table is in (`↓ cpu load`) and the heading of that
column is lit in the same colour, so the state is visible without asking. A
missing value is never a small one: a stopped machine has no load and a machine
whose guest is silent has no address, and both sort to the bottom whichever
direction the order runs. Machines that compare equal stay in name order, so
flipping the direction on a screen full of identical figures does not reshuffle
them.
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.
A vCenter that does not answer is named in the title in red — `on v308, v108
v38 unreachable` — for as long as the list is open, and the reason is on the
status line when it opens. Only the servers whose machines are actually there are
named as holding them.
### The machines that want looking at
A list of two hundred machines is read by running the eye down it, which is
exactly the wrong way to find the three that are broken. `^w` narrows it to
those, and each one carries the reason in place of its guest operating system:
NAME VC PWR SNAP IP HOST WHY
old01 v108 on 1 10.0.0.31 esx02 /var 97 % full · no VMware Tools
db01 v308 on 3 10.0.0.12 esx01 disks need consolidating · snapshot base is 63 days old
win7 v38 on - - esx07 vCenter says yellow
The four figures — vCPUs, processor load, memory and memory in use — are not
there. A machine is in this list because something is wrong with it, and how
hard its processors happen to be working at this second says nothing about any
of the reasons: they would be four columns of arithmetic between the machine's
name and the answer to the question that was asked. They are one keystroke away
in the ordinary list, and on the machine's own sheet.
Nothing new is asked of the servers: this is a filter over the sweep that is
already on screen, so it costs a keystroke and no waiting. `^w` again gives the
whole list back, the typed filter still applies inside it — `^w web` is the
broken web servers — and the title says `issues only` for as long as it is on,
because a filtered list that does not say so is a lie told by omission. `^o w`
puts the worst of them at the top.
What counts as an issue is deliberately narrow, because a list that cries wolf
is one nobody opens:
| reason | |
| --- | --- |
| disconnected, orphaned, inaccessible | vCenter cannot see the machine properly |
| waiting for an answer in vCenter | a question nobody has answered; the machine is stopped until somebody does |
| disks need consolidating | deltas left behind by a snapshot removal that did not finish, growing quietly |
| alarm: *name* | what vCenter itself is complaining about, by the name somebody gave the alarm |
| vCenter says red / yellow | the rolled-up status, when no alarm came with it to explain it |
| no VMware Tools | and only while the machine is running |
| */var* 97 % full | a guest filesystem past 90 %, named with the figure |
| snapshot *name* is 63 days old | past a month, which is where the table's red begins |
Broken is red and wants-a-look is yellow, worst first — which matters because
the column is truncated from the right. An alarm somebody has acknowledged is
one a person has dealt with already and is not reported; a machine that is
switched off is not a fault; and the things that are only true of a running
machine are not held against a stopped one.
The same list prints: `gvm vm -l --issues`, which is the morning's glance and
the one worth a cron job.
Nothing acts on a machine from the table. Everything that changes one lives in
the machine's own sheet, which `⏎` opens — a row of a table of two hundred
machines is something the eye runs past, not something anyone has read. In the
@@ -87,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.
@@ -103,13 +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. 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
@@ -119,8 +376,20 @@ 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
says so:
snapshots base (01.09.2026 02:00)
├─ after-patch (03.09.2026 09:12)
│ └─ hotfix (03.09.2026 16:40)
└─ before-boot (05.09.2026 07:00) ← current
The same drawing appears in the picker below and in `gvm snap -l`; there is one
function that draws it, so the three cannot drift apart.
`r` and `d` open a list of the machine's snapshots. What is chosen there is
carried on by its vSphere reference, not by its name: two snapshots of one
@@ -150,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`.
@@ -182,6 +455,155 @@ things hold for all of them:
One power operation per command line; two is a mistake, not a sequence, and gvm
says so instead of guessing.
The printed listing (`vm -l`) is the same table: the same columns, the same
cells, the same colours, fitted to the terminal when there is one and written out
in full into a pipe, where the colours are left off.
## The reports
Two things nothing in vCenter does for you, in the shape a cron job wants:
every server at once, one line per thing, and `-m` to put it in the post.
### Old snapshots
gvm snap --old # older than 30 days, on every vCenter
gvm snap --old -d 7 # or than a week
gvm snap --old -m # and mail it
Somebody takes a snapshot before an upgrade, the upgrade goes well, and the
snapshot stays. Six weeks later its delta disk is bigger than the machine and
the datastore is the thing that pages you.
3 snapshots older than 30 days, on 2 machines:
MACHINE VC SNAPSHOT AGE TAKEN SIZE
old01 v108 base 208d 12.02.2026 03:00 8.9GB
db01 v308 before-patch 63d 06.07.2026 22:14 41.2GB
db01 v308 hotfix 61d 08.07.2026 09:40 2.1GB
52.2GB in 3 snapshots, on 2 machines
Oldest first, which is the order the work is done in and puts the worst line
where a mail gets read. The age takes the table's colours — yellow past a week,
red past a month — and the mail carries the same table with the colours left
off, out of the same cells, so the two cannot come to different conclusions.
The size is what removing that snapshot would give back: its own state file and
the last link of each of its disk chains. The links in front of those belong to
its ancestors, and the delta the machine is writing to right now belongs to no
snapshot at all — so summing whole chains, which is the obvious thing to do,
reports the same delta once per descendant. A snapshot whose file layout could
not be read shows a dash rather than 0 B: nought bytes and "not known" are
different answers, and the second must not invite somebody to remove the wrong
snapshot.
The file layout is only asked for for the machines that actually have snapshots,
and for all of them at once per server — it lists every file of every machine,
which is far too much to carry through the ordinary sweep.
### Datastores
gvm ds # one line per datastore
gvm ds -t # and post the numbers
The gap next to `gvm host`: a cluster is watched by its processor load and its
memory, and then it falls over because a datastore filled up.
DATASTORE TYPE CAPACITY FREE USED% PROVISIONED OVER% VM STATUS STATE
ppb-ssd-1 VMFS 4.0TB 412GB 90 5.1TB 128 41 green ok
ppb-sata-2 VMFS 8.0TB 3.2TB 60 6.0TB 75 88 green ok
ppb-old VMFS - - - - - 3 red inaccessible
3 datastores 3.6TB of 12.0TB free (70 % used)
*Provisioned* is what has been promised out of the datastore: what is in use
plus what thin disks are still entitled to grow into. Past the capacity that is
a promise the datastore cannot keep if every machine takes what it was offered,
which is why it has a column of its own rather than being folded into "used" —
ordinary practice, so a hundred per cent is a word of warning in yellow and half
again as much is an alarm in red.
Every figure comes out of the datastore's summary, and vSphere only vouches for
those while the datastore is accessible: an unreachable one reports dashes
rather than zeroes, because a datastore that says 0 B free looks like an
emergency and one nobody can reach is a different one.
## The listing as a document
`gvm vm -l --json` is the same sweep, for something other than a person:
{
"generated": "2026-09-08T11:42:07+02:00",
"answered": ["v308", "v108"],
"failed": ["v38: login failed: ..."],
"count": 212,
"machines": [
{
"name": "db01",
"vcenter": "v308",
"power": "poweredOn",
"cpu_percent": 12.4,
"memory_percent": 64.1,
"snapshots": [{"name": "before-patch", "days": 63, "current": true, ...}],
"oldest_snapshot_days": 63,
"task": {"what": "consolid", "progress": 40, ...},
"issues": ["disks need consolidating"],
...
}
]
}
Two things about the shape, because a document is a promise:
It is one object and not an array of machines, because a listing that quietly
leaves out a vCenter which did not answer is worse than no listing at all — a
script handed a bare array cannot tell an empty cluster from an unreachable one.
The servers that answered and the ones that did not are in the document, and a
failure is *not* also printed as prose: a line of English in the middle of the
JSON would break whatever is reading it.
And a figure that is not known is `null`, never `0`. A stopped machine has no
processor load and a machine whose guest is silent has no address; a spreadsheet
that averages a column of zeroes reports a fleet that is idle.
`--json` and `--issues` mean `-l` without having to be told twice, and both take
`-m`, `--sort` and `--reverse` like any other listing.
## Shell completion
eval "$(gvm completion zsh)" # ~/.zshrc
gvm completion bash > /etc/bash_completion.d/gvm
Machine names are long and there are hundreds of them, which is what makes the
non-interactive half hard to type — `gvm -v v308 snap -l dbse<tab>`. The
completion offers them after the options that take a machine, the server names
after `-v`, and every subcommand and option otherwise.
That last half is not written down anywhere: flaggy generates it out of the
parser itself, so no list can fall behind the options that exist. gvm answers
the `completion` subcommand one step before flaggy would, keeps what flaggy
wrote, and adds the names on top — a wrapper that falls back to flaggy's own
function by the name it installed it under, read off the script rather than
written down a second time. `fish`, `powershell` and `nushell` are left to
flaggy entirely; the names are wired up for zsh and bash.
The names cannot come from the vCenters: a completion runs on every Tab and has
to answer in milliseconds, and three logins take seconds. So they come out of
what gvm last saw — every sweep of the machine list leaves them in the cache
directory, per server and with the time on them, and `--complete-vms` reads that
file and nothing else. A sweep of one server leaves the others' names where they
were, so completion keeps working for a vCenter that is down.
Neither the subcommand nor those two options read `~/.gvmrc`: a Tab key must not
rewrite a file, and reading the configuration seals any password standing in it
in the clear.
Nothing else in gvm reads that cache. Every command resolves the name it was
given against the server itself, because "what gvm saw last time somebody
looked" is the right currency for a Tab key and no currency at all for anything
that acts on a machine. `gvm config` says how old it is, so that a completion
offering a machine deleted last month can be explained.
## Colours
The palette is [mwxcol](https://git.micw.org/mike/mwxcol), copied into
@@ -190,12 +612,38 @@ is a change here. Everything gvm paints goes through that one file: the `P`/`PF`
helpers in `tools.go` through the `C*` functions, the full-screen list through
the escape sequences at the bottom of it.
The list follows the same mapping mwxcol's own fzf theme uses, job for job:
rows in `grey`, the selected row in `white` 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`. A machine that
is powered on is `green`, a suspended one `yellow`, and one that is simply off
is `dark` rather than red — being switched off is not a fault.
The frame follows the mapping mwxcol's own fzf theme uses, job for job: the
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:
| | |
| --- | --- |
| `white` | the machine's own name, and its guest |
| `violet` | which vCenter — a kind of thing |
| `green` / `dark` / `yellow` / `red` | powered on / off / suspended / anything else |
| `blue` | addresses, paths and dates |
| `orange` | sizes and counts |
| `pink` | names a person gave: snapshots |
| `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
sit on the `darker` surface, and every value on the sheet, where the labels
beside it are `dark` themselves. Lifted, it is still visibly quieter than the
rest of the palette, so what was dimmed stays dimmed.
Colours are written as true colour (24 bit). In a pipe the `C*` functions leave
them out; the full-screen list needs a terminal anyway.
@@ -219,12 +667,20 @@ running one, so a truncated or wrong-platform download cannot install itself.
./build.sh # all platforms into ./bin
PLATFORMS="linux/amd64" ./build.sh
VERSION=1.1.0 ./build.sh # set the version instead of bumping it
go test ./... # incl. tests against govmomi's simulator
Every run bumps the patch level in `version.txt` and injects it into the
binaries. The files in `./bin` are named the way `--update` expects them in a
release: `gvm-<goos>-<goarch>` on a release tagged with the bare version number.
The automatic bump only ever touches the last number, so `VERSION=` is how a
major or minor step is made — no number of builds reaches 1.0.0 from 0.x. It is
checked to be `MAJOR.MINOR.PATCH` before anything is built: that number ends up
in the binary, in `version.txt` and on the release tag, and `--update` compares
versions number by number, so anything else would compare as older than
everything and quietly stop updates.
## Tests
`go test ./...` runs without touching any real vCenter. The list, the filter,
@@ -243,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.
+225 -11
View File
@@ -47,6 +47,73 @@ func separator() menuItem { return menuItem{} }
func (m menuItem) isSeparator() bool { return m.key == 0 && m.label == "" }
// menuFacts are the lines of the machine's sheet worth repeating above the
// 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
// wording or colouring the same fact differently.
func sheetPick(sheet []sheetLine, labels []string) []sheetLine {
var out []sheetLine
for _, want := range labels {
for _, l := range sheet {
if l.label == want {
out = append(out, l)
break
}
}
}
return out
}
// openMenu reads what the machine currently has and puts the menu on screen.
// The snapshots are read here, once, so the menu knows whether reverting and
// removing are possible at all — and so the picker that follows works from a
@@ -81,12 +148,15 @@ func (b *browser) openMenu() {
}
b.menuSnaps = snaps
b.menu = b.buildMenu(*r, snaps)
// vmDetail asks nothing of the network; the snapshots are left out of it
// because the menu only wants the four lines above.
b.menuInfo = sheetPick(vmDetail(*r, nil, ""), menuFacts)
b.menuSel = 0
b.setStatus("", "")
}
func (b *browser) closeMenu() {
b.menu, b.menuSnaps, b.menuSel = nil, nil, 0
b.menu, b.menuSnaps, b.menuInfo, b.menuSel = nil, nil, nil, 0
}
// buildMenu is the menu for one machine in its current state.
@@ -403,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
}
@@ -462,13 +542,35 @@ func (b *browser) renderMenu() {
return
}
// 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 — 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)
segLine(&sb, cols, seg{colTitle, "Actions — " + r.name},
seg{colDim, SF(" %s, %s, %s", r.vc.Name, r.host, r.powerLong())})
segLine(&sb, cols,
seg{colTitle, "Actions — " + r.name},
seg{colDim, " "},
seg{colWhere, r.vc.Name},
seg{colDim, SF(" · %s · %s", r.vc.Datacenter, r.host)})
segLine(&sb, cols)
used := 2
for _, l := range info {
segLine(&sb, cols,
seg{colLabel, padRight(l.label, labelWidth)},
seg{l.col, l.value})
used++
}
if len(info) > 0 {
segLine(&sb, cols)
used++
}
for i, m := range b.menu {
if m.isSeparator() {
segLine(&sb, cols, seg{colDim, SR("─", min(cols, 46))})
@@ -500,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())
@@ -536,7 +639,7 @@ func (b *browser) renderPicker() {
if i == p.sel {
pointer, col = "▸ ", colRowSel
}
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.label()})
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.line()})
}
for i := end - p.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
@@ -612,29 +715,140 @@ func (b *browser) renderConfirm() {
b.write(sb.String())
}
// wrap breaks a sentence into lines of at most width, on spaces.
// wrap breaks a sentence into lines of at most width, on spaces — and, where
// there are none to break on, in the middle of the word.
//
// The second half is not a nicety. A datastore path is one long word, and on a
// narrow terminal the interesting end of it is the end; a line that simply runs
// past the edge loses exactly the part being looked up.
func wrap(s string, width int) []string {
if width < 8 {
width = 8
}
var out []string
line := ""
flush := func() {
if line != "" {
out = append(out, line)
line = ""
}
}
for _, w := range strings.Fields(s) {
for len([]rune(w)) > width { // longer than a whole line: chop it
flush()
r := []rune(w)
out = append(out, string(r[:width]))
w = string(r[width:])
}
switch {
case line == "":
line = w
case len([]rune(line))+1+len([]rune(w)) <= width:
line += " " + w
default:
out = append(out, line)
flush()
line = w
}
}
if line != "" {
out = append(out, line)
}
flush()
if len(out) == 0 {
return []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)
}
+313
View File
@@ -3,10 +3,13 @@ package main
import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -366,6 +369,19 @@ func TestWrap(t *testing.T) {
if len(wrap("", 20)) != 1 {
t.Error("wrapping nothing should still give one (empty) line")
}
// A word with nowhere to break — a datastore path — is chopped rather than
// left to run off the edge.
long := strings.Repeat("abcdefghij", 5) // 50 characters, no space
got = wrap("path "+long, 12)
for _, l := range got {
if len([]rune(l)) > 12 {
t.Errorf("an unbreakable word was left %d wide: %q", len([]rune(l)), l)
}
}
if joined := strings.ReplaceAll(strings.Join(got, ""), " ", ""); joined != "path"+long {
t.Errorf("chopping lost something: %q", joined)
}
}
// The menu's own column and the message shown when an entry is picked have to
@@ -448,3 +464,300 @@ func TestPowerOperationsSettleWhereTheyShould(t *testing.T) {
}
}
}
// The snapshot tree. Which state descends from which is the whole point of a
// snapshot list, so the drawing is checked branch by branch rather than by
// eyeballing it once.
func TestSnapshotTreeIsDrawn(t *testing.T) {
when := time.Date(2026, 9, 1, 2, 0, 0, 0, time.UTC)
snap := func(id, name string, children ...types.VirtualMachineSnapshotTree) types.VirtualMachineSnapshotTree {
return types.VirtualMachineSnapshotTree{
Snapshot: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: id},
Name: name,
CreateTime: when,
ChildSnapshotList: children,
}
}
// root-a
// ├─ child-1
// │ └─ grandchild
// └─ child-2
// root-b
roots := []types.VirtualMachineSnapshotTree{
snap("s1", "root-a",
snap("s2", "child-1", snap("s3", "grandchild")),
snap("s4", "child-2"),
),
snap("s5", "root-b"),
}
current := types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "s3"}
got := flattenSnapshots(roots, current)
want := []string{
"root-a",
"├─ child-1",
"│ └─ grandchild",
"└─ child-2",
"root-b",
}
if len(got) != len(want) {
t.Fatalf("the tree has %d entries, want %d", len(got), len(want))
}
for i, w := range want {
if prefix := got[i].prefix + got[i].name; prefix != w {
t.Errorf("line %d is %q, want %q", i, prefix, w)
}
}
// Parents come before their children, and each entry keeps its own reference
// — that is what the picker hands to the revert.
for i, ref := range []string{"s1", "s2", "s3", "s4", "s5"} {
if got[i].ref.Value != ref {
t.Errorf("entry %d is %s, want %s", i, got[i].ref.Value, ref)
}
}
// The one the machine is running from says so, and only that one.
for _, e := range got {
if marked := strings.Contains(e.line(), "current"); marked != (e.ref.Value == "s3") {
t.Errorf("%s: marked as current = %v", e.name, marked)
}
}
if !strings.Contains(got[2].line(), "01.09.2026") {
t.Errorf("the line carries no date: %q", got[2].line())
}
}
// A single snapshot is a tree of one, and must not be given a branch to hang off.
func TestLoneSnapshotHasNoBranch(t *testing.T) {
one := flattenSnapshots([]types.VirtualMachineSnapshotTree{{
Snapshot: types.ManagedObjectReference{Value: "s1"}, Name: "nightly",
}}, types.ManagedObjectReference{})
if len(one) != 1 {
t.Fatalf("one snapshot flattened to %d entries", len(one))
}
if one[0].prefix != "" {
t.Errorf("a lone snapshot is drawn with the branch %q", one[0].prefix)
}
if flattenSnapshots(nil, types.ManagedObjectReference{}) != nil {
t.Error("no snapshots should flatten to nothing at all")
}
}
// The facts above the choices come out of the sheet, so the two cannot word or
// colour the same fact differently.
func TestMenuFactsComeFromTheSheet(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
sheet := vmDetail(r, nil, "")
info := sheetPick(sheet, menuFacts)
if len(info) != len(menuFacts) {
t.Fatalf("the menu shows %d of the %d facts: %v", len(info), len(menuFacts), info)
}
for i, want := range menuFacts {
if info[i].label != want {
t.Errorf("fact %d is %q, want %q — the order asked for is the order shown", i, info[i].label, want)
}
var from sheetLine
for _, l := range sheet {
if l.label == want {
from = l
}
}
if info[i] != from {
t.Errorf("the menu's %q line is %+v, the sheet's %+v", want, info[i], from)
}
}
}
// A machine that says nothing about its guest gets fewer lines, not lines with a
// label and nothing after them.
func TestMenuFactsSkipWhatIsUnknown(t *testing.T) {
bare := vmRow{
vc: VCenter{Name: "v38"},
name: "half-there",
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{Name: "half-there"},
Runtime: types.VirtualMachineRuntimeInfo{PowerState: types.VirtualMachinePowerStatePoweredOff},
}},
}
for _, l := range sheetPick(vmDetail(bare, nil, ""), menuFacts) {
if strings.TrimSpace(l.value) == "" {
t.Errorf("the %q line is shown with nothing after it", l.label)
}
}
if got := sheetPick(nil, menuFacts); got != nil {
t.Errorf("picking from an empty sheet gave %v", got)
}
}
// On a terminal too short for both, the facts go and the choices stay: a menu
// whose entries have scrolled off the top is worse than one without a header.
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, 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)
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
b.menu = b.buildMenu(r, nil)
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
if got := strings.Contains(stripEscapes(frame), "hostname"); got != c.wantFacts {
t.Errorf("%s rows: facts shown = %v, want %v", c.rows, got, c.wantFacts)
}
// The choices are there either way, and so is the machine's name.
for _, want := range []string{"take a snapshot", "power off", "web01"} {
if !strings.Contains(stripEscapes(frame), want) {
t.Errorf("%s rows: the menu lost %q", c.rows, want)
}
}
}
}
// renderToPipe draws one screen into a pipe and hands back what was written.
func renderToPipe(t *testing.T, b *browser, draw func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
b.tty = w
draw()
w.Close()
out, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(out)
}
// A control sequence has to be swallowed whole. Reading a fixed number of bytes
// left the rest of a longer one in the stream, where the next read took it for
// typing: Ctrl-Up put "5A" into the filter and F5 put a tilde in it, having first
// jumped to the top of the list.
func TestEscapeSequencesAreConsumedWhole(t *testing.T) {
for _, c := range []struct {
send string
want specialKey
note string
}{
{"\x1b[A", keyUp, "up"},
{"\x1b[B", keyDown, "down"},
{"\x1b[C", keyRight, "right"},
{"\x1b[D", keyLeft, "left"},
{"\x1b[H", keyHome, "home"},
{"\x1b[F", keyEnd, "end"},
{"\x1b[Z", keyShiftTab, "shift-tab"},
{"\x1b[3~", keyDelete, "delete"},
{"\x1b[5~", keyPgUp, "page up"},
{"\x1b[6~", keyPgDn, "page down"},
// Modified arrows and function keys: not answered, but not leaked either.
{"\x1b[1;5A", keyNone, "ctrl-up"},
{"\x1b[1;2D", keyNone, "shift-left"},
{"\x1b[15~", keyNone, "F5"},
{"\x1b[200~", keyNone, "a bracketed paste opening"},
{"\x1b[<0;10;20M", keyNone, "a mouse report"},
} {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
kr := newKeyReader(r)
w.WriteString(c.send + "x") // an "x" behind it, to see what was left over
if got := kr.next(); got.special != c.want {
t.Errorf("%s (%q): decoded as %v, want %v", c.note, c.send, got.special, c.want)
}
// Whatever the sequence was, the very next key must be the x — nothing of
// the sequence may arrive as text.
next := kr.next()
if next.special != keyRune || next.r != 'x' {
t.Errorf("%s (%q): the next key is %v/%q, want the x — part of the sequence leaked",
c.note, c.send, next.special, next.r)
}
w.Close()
r.Close()
}
}
// A lone Esc is still a lone Esc: it is how every dangerous question is abandoned.
func TestLoneEscapeIsStillEscape(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
kr := newKeyReader(r)
w.WriteString("\x1b")
if got := kr.next(); got.special != keyEsc {
t.Errorf("a lone Esc decoded as %v", got.special)
}
}
// Every vCenter task gvm waits for has to be waited for with a bound. This is a
// property of the source rather than of anything a test can provoke — a task that
// hangs is exactly what no simulator will do — so the source is what is checked.
//
// Taking a snapshot was the one that got away: it waited on the session's own
// context, which has no deadline, and would have frozen the interactive list
// mid-draw with no key being read.
func TestEveryTaskWaitIsBounded(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
found := 0
for _, name := range files {
if strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(src), "\n") {
code := strings.TrimSpace(line)
if !strings.Contains(code, ".Wait(") || strings.HasPrefix(code, "//") {
continue
}
if strings.Contains(code, "wg.Wait()") {
continue // a WaitGroup, not a vCenter task
}
if strings.Contains(code, "cmd.Wait()") {
continue // an exec.Cmd — a browser being handed a URL (guest.go)
}
found++
// The one place a task may be waited on is inside waitTask, which
// gives it a deadline of its own.
if name != "power.go" || !strings.Contains(code, "task.Wait(wctx)") {
t.Errorf("%s:%d waits on a task outside waitTask: %s", name, i+1, code)
}
}
}
if found == 0 {
t.Error("no task wait found at all — this check has stopped checking anything")
}
}
+962 -214
View File
File diff suppressed because it is too large Load Diff
+1037 -17
View File
File diff suppressed because it is too large Load Diff
+26 -8
View File
@@ -13,20 +13,38 @@
#
# Override the platform list to build just one:
# PLATFORMS="linux/amd64" ./build.sh
#
# Set the version instead of bumping it:
# VERSION=1.0.0 ./build.sh
# The automatic bump only ever touches the last number, so no number of builds
# ever reaches 1.0.0 from 0.x — a major or minor step is a decision, and this is
# how it is made.
set -e
cd "$(dirname "$0")"
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64"}
V=$(cat version.txt 2>/dev/null || echo 0.1.0)
if [ -n "$VERSION" ]; then
# Checked rather than trusted: this number goes into the binary, into
# version.txt and onto the release tag, and `gvm --update` compares versions
# number by number — something that is not MAJOR.MINOR.PATCH would compare as
# older than everything and quietly stop updates.
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "VERSION=$VERSION is not MAJOR.MINOR.PATCH" >&2
exit 1
fi
NV="$VERSION"
else
V=$(cat version.txt 2>/dev/null || echo 0.1.0)
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 0.1.9 -> 0.1.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 0.1.9 -> 0.1.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
fi
# earlier versions built ./gvm in the repo root; drop it so nothing keeps
# running a stale binary from a path that is no longer written
+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
}
-4
View File
@@ -4,10 +4,8 @@ go 1.26.1
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/eknkc/basex v1.0.1
github.com/fatih/color v1.19.0
github.com/integrii/flaggy v1.8.0
github.com/tidwall/gjson v1.18.0
github.com/vmware/govmomi v0.53.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)
@@ -18,8 +16,6 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.34.0 // indirect
-8
View File
@@ -7,8 +7,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -35,12 +33,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/vmware/govmomi v0.53.0 h1:e1bZCotAq7wm4xy95ePN2uoWwz28pNp/ewZZhpBY7/4=
github.com/vmware/govmomi v0.53.0/go.mod h1:EWfuzPfxT5NV+aS2we02SLFdhvJkgeY7t7+TszgBSMY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+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
}
}
+163 -27
View File
@@ -16,14 +16,38 @@ package main
import (
"os"
"strings"
"github.com/integrii/flaggy"
)
// helpTail is what `gvm -h` prints after flaggy's own blocks.
//
// The two update options are answered before the parser exists (see updateFlags),
// so flaggy has never heard of them and would leave them out of its Flags block.
// They are spelled out here instead: a self-updating program whose help does not
// mention how is a program nobody updates. --version needs no line, flaggy lists
// that one itself, and the background refresh flag deliberately has none.
//
// Bare `gvm` browses instead of printing this help, so the help says that too —
// otherwise the one command a newcomer types is the one that does something
// unannounced.
var helpTail = strings.Join([]string{
" Updating:",
" --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")
// version is a var, not a const, so build.sh can inject the current build
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces.
var version = "0.1.0"
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.1.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -32,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
@@ -46,15 +80,11 @@ func run() error {
flaggy.SetName("gvm")
flaggy.SetDescription("VMware command line helper (mwx'2026)")
flaggy.SetVersion(version)
// Bare `gvm` browses instead of printing this help, so the help itself has
// to say so — otherwise the one command a newcomer types is the one that
// does something unannounced.
flaggy.DefaultParser.AdditionalHelpAppend =
"\n Run 'gvm' with no subcommand to browse the machines interactively."
flaggy.DefaultParser.AdditionalHelpAppend = helpTail // see its comment above
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
@@ -64,11 +94,19 @@ func run() error {
subVM.Bool(&vmList, "l", "list", "Print the machines instead of browsing them")
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
var vmSort string
var vmReverse, vmIssues, vmJSON bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
// The destructive options deliberately have no short letter: --revert and
// --removeall have to be spelled out, so neither can be reached by a slip of
// one key next to a harmless one.
var snapLs, snapNew_, snapRm, snapRmAll, snapRevertTo, snapName string
var snapOld, snapMail bool
snapDays := snapOldDays
subSnap := flaggy.NewSubcommand("snap")
subSnap.Description = "Snapshot commands"
subSnap.String(&snapLs, "l", "list", "List the snapshots of <vm>")
@@ -77,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")
@@ -93,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")
@@ -101,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()
@@ -121,12 +189,26 @@ func run() error {
if err != nil {
return err
}
if vmList { // -l prints; anything else browses
return lsvm(targets, vmMatch)
// --json and --issues are ways of printing, so they mean -l without
// having to be told twice: browsing a machine list as JSON is not a
// thing, and a full-screen list has ^w for the other one.
if vmList || vmJSON || vmIssues {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch)
return browseVMs(targets, vmMatch, cfg.SSH)
case subSnap.Used:
if snapOld {
targets, err := cfg.targets(vcname)
if err != nil {
return err
}
if snapDays < 0 {
return errf("-d wants a number of days, not %d", snapDays)
}
return snapOldReport(cfg, targets, snapDays, snapMail)
}
vc, err := cfg.pick(vcname)
if err != nil {
return err
@@ -191,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("")
@@ -214,6 +304,9 @@ func run() error {
return vmlog(cfg, vc, logMinutes, logMail)
case subConfig.Used:
if cfgPassword != "" {
return setPassword(cfg, cfgPassword)
}
return showConfig(cfg)
}
@@ -228,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
@@ -247,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)
@@ -259,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 "-"
@@ -270,14 +378,45 @@ func orNone(s string) string {
return s
}
// updateFlags answers the self-update options and reports whether it did. They
// are spelled with dashes and handled before the flag parser, because they are
// what has to work when there is no configuration to read yet.
// updateFlags answers the self-update options and reports whether it did.
//
// They are spelled with dashes and answered here, before the flag parser exists,
// because they are what has to work when there is no configuration to read yet —
// and because `--version` is what the freshly downloaded binary is probed with,
// on a machine that may never have run gvm.
//
// Every argument is looked at, not only the first: asking for an update is
// absolute, so `gvm -v v308 --update` means the same as `gvm --update` rather
// than quietly meaning nothing, which is what checking os.Args[1] alone did.
func updateFlags() bool {
if len(os.Args) < 2 {
arg, ok := updateFlagIn(os.Args[1:])
if !ok {
return false
}
switch os.Args[1] {
updateFlag(arg)
return true
}
// updateFlagIn finds the first update option among the arguments, wherever it
// stands.
func updateFlagIn(args []string) (string, bool) {
for _, a := range args {
if isUpdateFlag(a) {
return a, true
}
}
return "", false
}
// updateFlagNames are the options answered before the parser. Kept as data so
// that what the scan recognises can be checked against what the help promises,
// without having to carry out an update to find out.
var updateFlagNames = []string{"--version", "--update", "--check-update", updateRefreshFlag}
func isUpdateFlag(arg string) bool { return contains(updateFlagNames, arg) }
func updateFlag(arg string) {
switch arg {
case "--version":
PF("gvm %s\n", version)
case "--update":
@@ -292,10 +431,7 @@ func updateFlags() bool {
}
case updateRefreshFlag: // the background run, not in the help
selfUpdate.refresh()
default:
return false
}
return true
}
// updateNote prints the once-a-day hint, when there is one. It costs nothing:
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// The options answered before the flag parser. Because flaggy never sees them, a
// new one could be handled and never documented — which is how `gvm -h` came to
// leave out --update and --check-update in the first place.
func TestHelpMentionsEveryUpdateOptionItAnswers(t *testing.T) {
for _, f := range updateFlagNames {
switch f {
case "--version":
continue // flaggy prints a line for its own version flag
case updateRefreshFlag:
if strings.Contains(helpTail, f) {
t.Errorf("%s is the background call and has no business in the help", f)
}
continue
}
if !strings.Contains(helpTail, f) {
t.Errorf("gvm answers %s but the help does not mention it", f)
}
}
// 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, "--") {
continue
}
if !isUpdateFlag(word) && !isCompletionFlag(word) {
t.Errorf("the help offers %s, which nothing answers", word)
}
}
}
}
func TestIsUpdateFlag(t *testing.T) {
for _, yes := range []string{"--version", "--update", "--check-update", updateRefreshFlag} {
if !isUpdateFlag(yes) {
t.Errorf("%s is not recognised", yes)
}
}
for _, no := range []string{"", "-v", "--vcenter", "vm", "update", "--updates", "--UPDATE", "-u"} {
if isUpdateFlag(no) {
t.Errorf("%q is taken for an update option", no)
}
}
}
// An update is asked for wherever the option stands: checking only the first
// argument meant `gvm -v v308 --update` quietly did nothing of the sort.
func TestUpdateOptionIsFoundAnywhere(t *testing.T) {
for _, c := range []struct {
args []string
want string
}{
{[]string{"--update"}, "--update"},
{[]string{"-v", "v308", "--update"}, "--update"},
{[]string{"--check-update", "-v", "v308"}, "--check-update"},
{[]string{"vm", "-l"}, ""},
{[]string{}, ""},
{[]string{"snap", "-n", "a-machine"}, ""},
} {
got, ok := updateFlagIn(c.args)
if (c.want != "") != ok {
t.Errorf("%v: found = %v, want %v", c.args, ok, c.want != "")
}
if got != c.want {
t.Errorf("%v: found %q, want %q", c.args, got, c.want)
}
}
}
// The version compiled in and the version last built have to belong to the same
// line of development. Only the first two numbers are compared: build.sh bumps
// the last one on every build and does not touch the source, so demanding they
// match exactly would fail after every build — while a source that still said
// 0.1 after the move to 1.0 is exactly the mistake worth catching.
func TestCompiledVersionMatchesTheLineOfDevelopment(t *testing.T) {
built, err := os.ReadFile("version.txt")
if err != nil {
t.Skipf("no version.txt to compare against: %v", err)
}
line := func(v string) string {
p := strings.Split(strings.TrimSpace(v), ".")
if len(p) < 2 {
t.Fatalf("%q is not a MAJOR.MINOR.PATCH version", v)
}
return p[0] + "." + p[1]
}
if got, want := line(version), line(string(built)); got != want {
t.Errorf("the source says %s (%s), version.txt says %s (%s)",
version, got, strings.TrimSpace(string(built)), want)
}
// And it has to be a version at all, or --update would compare it as older
// than everything and stop updating.
if !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(version) {
t.Errorf("the compiled version %q is not MAJOR.MINOR.PATCH", version)
}
}
+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,
+32 -46
View File
@@ -35,40 +35,36 @@ func hoststat(vc VCenter, telemetry string) error {
return err
}
P()
P("Name CPU Used Mem Total Mem VM ON Status Connected")
printRow(hostColumns, "", nil) // the heading, from the same widths as the rows
for _, host := range hosts {
hn := shortHost(host.Name)
total := len(host.Vm)
on := countOn(host.Vm, running)
var usedMem, totalMem int64
usedMem = int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
cpustr := " -"
usedMem := int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
totalMem := int64(0)
cpu, cpuKnown := 0.0, false
if hw := host.Summary.Hardware; hw != nil {
totalMem = hw.MemorySize
if cpu, ok := cpuPercent(hw, host.Summary.QuickStats); ok {
cpustr = colorPercent(cpu)
}
cpu, cpuKnown = cpuPercent(hw, host.Summary.QuickStats)
}
cpuText, cpuCol := "-", colOff
if cpuKnown {
cpuText, cpuCol = SF("%.2f", cpu), loadColor(cpu, true)
}
PF("%4s %s %8s %8s %3d %3d %6s %s\n",
Cwb(hn),
cpustr,
units.ByteSize(usedMem),
units.ByteSize(totalMem),
total,
on,
colorStatus(host.OverallStatus),
host.Runtime.ConnectionState,
)
printRow(hostColumns, "", []cell{
{hn, cWhite.fg()},
{cpuText, cpuCol},
{units.ByteSize(usedMem).String(), colSize},
{units.ByteSize(totalMem).String(), colSize},
{Itoa(total), colSize},
{Itoa(on), colSize},
{string(host.OverallStatus), statusColor(host.OverallStatus)},
{string(host.Runtime.ConnectionState), colAside},
})
if telemetry != "" {
cpu := 0.0
if hw := host.Summary.Hardware; hw != nil {
cpu, _ = cpuPercent(hw, host.Summary.QuickStats)
}
post(telemetry, SF("vm,%s,%.2f,%d,%d,%d,%d,%s,%s",
hn, cpu, usedMem, totalMem, total, on,
host.OverallStatus, host.Runtime.ConnectionState))
@@ -95,10 +91,13 @@ func vmstat(vc VCenter) error {
return err
}
PF("%-25s %-10s %-10s\n", "Host", "Total VMs", "PoweredOn")
P(SR("-", 47))
printRow(countColumns, "", nil)
for _, host := range hosts {
PF("%-25s %-10d %-10d\n", host.Name, len(host.Vm), countOn(host.Vm, running))
printRow(countColumns, "", []cell{
{host.Name, cWhite.fg()},
{Itoa(len(host.Vm)), colSize},
{Itoa(countOn(host.Vm, running)), colSize},
})
}
return nil
}
@@ -143,30 +142,17 @@ func cpuPercent(hw *types.HostHardwareSummary, qs types.HostListSummaryQuickStat
return 100.0 / float64(totalMHz) * float64(qs.OverallCpuUsage), true
}
func colorPercent(p float64) string {
s := SF("%6.2f", p)
switch {
case p > 90:
return Crb(s)
case p > 50:
return Cyb(s)
case p > 2:
return Cwb(s)
}
return s
}
func colorStatus(st types.ManagedEntityStatus) string {
s := string(st)
switch s {
// statusColor: vSphere's own words for how a host is doing, in the palette's.
func statusColor(st types.ManagedEntityStatus) string {
switch string(st) {
case "green":
return Cgb(s)
return colOK
case "yellow":
return Cyb(s)
return colBusy
case "red":
return Crb(s)
return colFull
}
return s
return colAside
}
// shortHost is the hostname without its domain, which is all the first column
+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])
}
}
+3 -2
View File
@@ -40,8 +40,9 @@ func vmlog(cfg Config, vc VCenter, minutes int, mail bool) error {
return fmt.Errorf("%s: cannot read the event log: %w", vc.Name, err)
}
PF("%-20s | %-15s | %s\n", "Time", "Severity", "Message")
P(SR("-", 80))
// No heading and no rule: the lines below are comma-separated on purpose —
// they are what the mail carries and what a script would cut up — and a
// pipe-separated heading above them promised a table that never came.
msg := ""
for _, e := range events {
+129
View File
@@ -0,0 +1,129 @@
// print.go — columns for the commands that print rather than draw.
//
// The one rule: pad first, colour afterwards. A colour is a handful of escape
// bytes that occupy no columns on screen, but %8s counts them all the same — so a
// coloured value handed to a width verb is never padded, and any value longer
// than its column shoves everything after it to the right. `gvm host` did exactly
// that: a host called DC0_C0_H0 shifted its whole line three columns against the
// one above it, and the heading, being written by hand, lined up with neither.
package main
import (
"strings"
"github.com/fatih/color"
)
// printColumn is one column of a printed table: what it is called, how wide it
// is, and whether its values are numbers, which read better against the right.
type printColumn struct {
header string
width int
right bool
}
// cell is one value with the colour it is shown in.
type cell struct {
text string
col string
}
var hostColumns = []printColumn{
{header: "HOST", width: 10},
{header: "CPU%", width: 6, right: true},
{header: "USED MEM", width: 9, right: true},
{header: "TOTAL MEM", width: 9, right: true},
{header: "VM", width: 4, right: true},
{header: "ON", width: 4, right: true},
{header: "STATUS", width: 7},
{header: "CONNECTED", width: 12},
}
var countColumns = []printColumn{
{header: "HOST", width: 24},
{header: "VM", width: 4, right: true},
{header: "ON", width: 4, right: true},
}
// printRow writes one row, or the heading when cells is nil. Every value is
// padded to its column as plain text and only then coloured, so a long value is
// cut instead of pushing its neighbours along.
func printRow(cols []printColumn, indent string, cells []cell) {
out := make([]string, 0, len(cols))
for i, c := range cols {
text, col := c.header, colHeader
if cells != nil {
if i >= len(cells) {
break
}
text, col = cells[i].text, cells[i].col
}
out = append(out, paint(pad(text, c.width, c.right), col))
}
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
}
// widen grows every column to the longest thing it holds, header included. For
// output that is going somewhere without a width of its own, where cutting a
// value would lose it rather than merely hide it.
func widen(pcs []printColumn, body [][]cell) {
for i := range pcs {
w := len([]rune(pcs[i].header))
for _, cells := range body {
if i >= len(cells) {
continue
}
if n := len([]rune(cells[i].text)); n > w {
w = n
}
}
pcs[i].width = w
}
}
// pad fits text to width — truncating what is too long — against the left or,
// for numbers, the right.
func pad(text string, width int, right bool) string {
t := truncate(text, width)
gap := width - len([]rune(t))
if gap <= 0 {
return t
}
if right {
return SR(" ", gap) + t
}
return t + SR(" ", gap)
}
// paint colours text, but only where colour belongs. The escapes the interactive
// screen uses are written straight out and would otherwise end up in a pipe;
// fatih/color already works out whether that is the case, so its answer is the
// one used here rather than a second opinion.
func paint(text, col string) string {
if col == "" || color.NoColor {
return text
}
return col + text + attrOff
}
// plainRow is one row with nothing in it but the values — the same cells and
// the same widths as printRow writes to the screen, without the colour.
//
// For output that leaves the machine: the mail a report sends is read in a mail
// client, where an escape sequence is not a colour but four stray characters,
// and fatih/color's answer to "is this a terminal" is about this process's
// stdout and says nothing about where a mail is going.
func plainRow(cols []printColumn, cells []cell) string {
out := make([]string, 0, len(cols))
for i, c := range cols {
text := c.header
if cells != nil {
if i >= len(cells) {
break
}
text = cells[i].text
}
out = append(out, pad(text, c.width, c.right))
}
return strings.TrimRight(strings.Join(out, " "), " ")
}
+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)
}
}
+518 -18
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
@@ -68,6 +75,9 @@ func startTools(t *testing.T, model *simulator.Model, ref types.ManagedObjectRef
vm.Summary.Guest.ToolsStatus = vm.Guest.ToolsStatus
}
// joinSnaps flattens what snapshotLines found, dropping the colour it chose.
func joinSnaps(lines []string, _ string) string { return strings.Join(lines, "\n") }
// powerOf reads a machine's power state straight from the server, which is the
// only answer worth asserting on after a power operation.
func powerOf(t *testing.T, s *session, ref types.ManagedObjectReference) types.VirtualMachinePowerState {
@@ -82,7 +92,8 @@ func powerOf(t *testing.T, s *session, ref types.ManagedObjectReference) types.V
// oneRow connects and returns the machine of that name as the list would hold it.
func oneRow(t *testing.T, vc VCenter, name string) (*session, vmRow) {
t.Helper()
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -113,7 +124,8 @@ func TestSimGatherVMs(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -157,7 +169,8 @@ func TestSimBrowseAndOpenDetail(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -180,8 +193,19 @@ func TestSimBrowseAndOpenDetail(t *testing.T) {
if b.detail == nil {
t.Fatal("enter did not open the sheet")
}
// The machine and where it lives are the title; the sheet itself is the
// facts about it.
if !strings.Contains(b.dtitle, want) {
t.Errorf("the title is %q, expected it to name %s", b.dtitle, want)
}
if b.dvc != "sim" {
t.Errorf("the title names the vCenter as %q", b.dvc)
}
if !strings.Contains(b.dwhere, "DC0") {
t.Errorf("the title's tail %q does not say which datacenter", b.dwhere)
}
sheet := sheetText(b.detail)
for _, s := range []string{want, "snapshots", "moref", "sim"} {
for _, s := range []string{"snapshots", "moref", "state"} {
if !strings.Contains(sheet, s) {
t.Errorf("the sheet does not mention %q\n%s", s, sheet)
}
@@ -206,7 +230,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -221,7 +246,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
}
// The sheet has to show it now.
rows, sessions, err = gatherVMs([]VCenter{vc})
found, err = gatherVMs([]VCenter{vc})
rows, sessions = found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -232,7 +258,7 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
row = r
}
}
lines := strings.Join(snapshotLines(row), "\n")
lines := joinSnaps(snapshotLines(row))
if strings.Contains(lines, "none") || strings.Contains(lines, "cannot read") {
t.Errorf("the sheet shows no snapshot after taking one: %q", lines)
}
@@ -240,7 +266,7 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
if err := snapRemoveAll(vc, name, true); err != nil {
t.Fatalf("snapRemoveAll: %v", err)
}
if lines := strings.Join(snapshotLines(row), "\n"); !strings.Contains(lines, "none") {
if lines := joinSnaps(snapshotLines(row)); !strings.Contains(lines, "none") {
t.Errorf("snapshots left after removing all: %q", lines)
}
}
@@ -250,10 +276,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
vc := simVCenter(t)
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
if err := lsvm([]VCenter{vc}, ""); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{}); err != nil {
t.Errorf("lsvm: %v", err)
}
if err := lsvm([]VCenter{vc}, "DC0"); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "DC0"}); err != nil {
t.Errorf("lsvm with a pattern: %v", err)
}
if err := hoststat(vc, ""); err != nil {
@@ -267,7 +293,7 @@ func TestSimReadOnlyCommands(t *testing.T) {
}
// A pattern that is not a regexp is a message, not a panic.
if err := lsvm([]VCenter{vc}, "web("); err == nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "web("}); err == nil {
t.Error("lsvm accepted a broken pattern")
}
}
@@ -307,7 +333,8 @@ func TestSimInteractiveSnapshot(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -355,7 +382,7 @@ func TestSimInteractiveSnapshot(t *testing.T) {
if !strings.Contains(b.status, "nothing done") {
t.Errorf("status after Esc in the name field: %q", b.status)
}
if lines := strings.Join(snapshotLines(*r), "\n"); !strings.Contains(lines, "none") {
if lines := joinSnaps(snapshotLines(*r)); !strings.Contains(lines, "none") {
t.Fatalf("a snapshot was taken although the name was abandoned: %q", lines)
}
@@ -365,7 +392,7 @@ func TestSimInteractiveSnapshot(t *testing.T) {
if !strings.Contains(b.status, "nothing done") {
t.Errorf("status after declining: %q", b.status)
}
if lines := strings.Join(snapshotLines(*r), "\n"); !strings.Contains(lines, "none") {
if lines := joinSnaps(snapshotLines(*r)); !strings.Contains(lines, "none") {
t.Fatalf("a snapshot was taken although the question was declined: %q", lines)
}
@@ -382,7 +409,7 @@ func TestSimInteractiveSnapshot(t *testing.T) {
if !strings.Contains(b.status, want) {
t.Errorf("the status does not name the snapshot: %q", b.status)
}
lines := strings.Join(snapshotLines(*r), "\n")
lines := joinSnaps(snapshotLines(*r))
if !strings.Contains(lines, want) {
t.Fatalf("the snapshot is not called %q:\n%s", want, lines)
}
@@ -628,7 +655,8 @@ func TestSimBrowserDestructiveNeedsTheName(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -668,6 +696,13 @@ func TestSimBrowserDestructiveNeedsTheName(t *testing.T) {
if len(b.menuSnaps) != 1 {
t.Fatalf("the menu sees %d snapshots", len(b.menuSnaps))
}
// And the facts above the choices, read from the machine as it is now.
if len(b.menuInfo) == 0 {
t.Error("the menu shows no facts above the choices")
}
if b.menuInfo[0].label != "state" || !strings.Contains(b.menuInfo[0].value, "poweredOn") {
t.Errorf("the first fact is %+v, expected the machine's state", b.menuInfo[0])
}
// 'd' opens the picker; Enter chooses the snapshot and lands on the
// confirmation, which is answered with the wrong name.
@@ -713,7 +748,8 @@ func TestSimBrowserPowerOffNeedsTheName(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -779,7 +815,8 @@ func TestSimMenuRereadsTheRow(t *testing.T) {
quiet(t)
vc, model := simVCenterModel(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -861,3 +898,466 @@ func TestBrowserSaysWhenTheRowCannotBeReread(t *testing.T) {
t.Error("a stale row is not flagged as a warning")
}
}
// A vCenter that does not answer has to be visible in the list that is meant to
// show every vCenter. It used to be printed and then wiped by the first screen,
// while the title went on naming the server as though its machines were there.
func TestSimUnreachableVCenterIsNamed(t *testing.T) {
quiet(t)
live := simVCenter(t)
dead := VCenter{
Name: "dead", URL: "http://127.0.0.1:9/",
User: "user", Password: "pass", Datacenter: "DC0",
}
found, err := gatherVMs([]VCenter{live, dead})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("one server answering should not be a failure: %v", err)
}
if len(found.rows) == 0 {
t.Fatal("the machines of the server that answered were thrown away")
}
if strings.Join(found.answered, ",") != "sim" {
t.Errorf("answered: %v, want only sim", found.answered)
}
if strings.Join(found.lost, ",") != "dead" {
t.Errorf("lost: %v, want only dead", found.lost)
}
if len(found.failed) != 1 || !strings.Contains(found.failed[0], "dead") {
t.Errorf("the reason does not name the server: %v", found.failed)
}
// The title names one as holding the machines and the other as missing, and
// does not claim the dead one holds anything.
b := &browser{targets: []VCenter{live, dead}, rows: found.rows,
answered: found.answered, lost: found.lost}
b.applySort()
t.Setenv("COLUMNS", "120")
t.Setenv("LINES", "14")
frame := stripEscapes(renderToPipe(t, b, b.renderList))
title := strings.SplitN(frame, "\r\n", 2)[0]
if !strings.Contains(title, "on sim") {
t.Errorf("the title does not say whose machines these are: %q", title)
}
if !strings.Contains(title, "dead unreachable") {
t.Errorf("the title does not say that dead is missing: %q", title)
}
if strings.Contains(title, "on dead") || strings.Contains(title, "sim, dead") {
t.Errorf("the title still claims the list covers dead: %q", title)
}
}
// Every vCenter failing is a failure of the command; one failing is not.
func TestSimAllVCentersUnreachable(t *testing.T) {
quiet(t)
dead := func(name, port string) VCenter {
return VCenter{Name: name, URL: "http://127.0.0.1:" + port + "/",
User: "u", Password: "p", Datacenter: "DC0"}
}
found, err := gatherVMs([]VCenter{dead("a", "9"), dead("b", "9")})
closeSessions(found.sessions)
if err == nil {
t.Fatal("no server answered and that was not an error")
}
if len(found.failed) != 2 {
t.Errorf("%d reasons for two failures", len(found.failed))
}
if len(found.answered) != 0 {
t.Errorf("answered: %v", found.answered)
}
}
// ------------------------------------------------- what the new commands read
// The sweep brings the snapshots back with the machines, which is what the
// snapshot column, the issues filter and the age report are all made of.
func TestSimSweepCarriesTheSnapshots(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
if err := snapshotNow(s, r.ref, "sweep-test", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
found, err := gatherVMs([]VCenter{vc})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
for _, row := range found.rows {
if row.name != "DC0_C0_RP0_VM0" {
continue
}
if row.snapCount() != 1 {
t.Fatalf("the sweep found %d snapshots, want 1", row.snapCount())
}
if row.snapCell() != "1" {
t.Errorf("the column shows %q", row.snapCell())
}
e, ok := row.oldest()
if !ok {
t.Fatal("the snapshot came back without a date")
}
if e.name != "sweep-test" {
t.Errorf("the oldest snapshot is %q", e.name)
}
// Taken a moment ago, so it is neither stale nor an issue.
if days := e.days(); days != 0 {
t.Errorf("a snapshot taken just now is %d days old", days)
}
if row.hasIssues() && strings.Contains(strings.Join(row.issues(), " "), "snapshot") {
t.Errorf("a fresh snapshot is held against the machine: %v", row.issues())
}
return
}
t.Fatal("the machine was not in the sweep")
}
// The age report, end to end: a snapshot taken now is not old, and the same
// report with an age of nothing finds it.
func TestSimSnapshotAgeReport(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if err := snapshotNow(s, r.ref, "ancient", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
// Nothing is a month old on a vCenter that was created a second ago — and
// where somebody is reading, the report says so rather than printing an
// empty table.
onATerminal(t)
out := captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, snapOldDays, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("the report of old snapshots says:\n%s", out)
}
// Everything is nought days old, so an age of nought finds it.
out = captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, 0, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
for _, want := range []string{"ancient", "DC0_C0_RP0_VM1", "0d"} {
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the report leaves out %q:\n%s", want, out)
}
}
}
// A mailed report with no relay configured is refused before the sweep, not
// after it: finding out that the mail cannot be sent is of no use once the
// report has been printed.
func TestSimSnapshotReportChecksTheMailFirst(t *testing.T) {
quiet(t)
err := snapOldReport(Config{}, []VCenter{{Name: "nowhere", URL: "https://127.0.0.1:1/",
User: "u", Password: "p", Datacenter: "DC0"}}, 30, true)
if err == nil || !strings.Contains(err.Error(), "cannot send mail") {
t.Errorf("a report with -m and no relay failed with: %v", err)
}
}
func TestSimDatastores(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := dsstat(vc, ""); err != nil {
t.Fatalf("dsstat: %v", err)
}
})
plain := stripEscapes(out)
for _, want := range []string{"DATASTORE", "CAPACITY", "USED%", "LocalDS_0", "datastore"} {
if !strings.Contains(plain, want) {
t.Errorf("the datastore table leaves out %q:\n%s", want, plain)
}
}
}
// The printed listing as a document: what a monitoring check would read.
func TestSimJSONListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Moref string `json:"moref"`
Power string `json:"power"`
} `json:"machines"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("the listing is not JSON: %v\n%s", err, out)
}
if len(doc.Answered) != 1 || doc.Answered[0] != "sim" {
t.Errorf("answered = %v", doc.Answered)
}
if doc.Count != len(doc.Machines) || doc.Count == 0 {
t.Errorf("count = %d, machines = %d", doc.Count, len(doc.Machines))
}
for _, m := range doc.Machines {
if m.Name == "" || m.Moref == "" || m.VCenter != "sim" || m.Power == "" {
t.Errorf("a machine came out as %+v", m)
}
}
}
// A vCenter that does not answer is in the document rather than printed into
// the middle of it, where it would break whatever is reading it.
func TestSimJSONKeepsTheProseOut(t *testing.T) {
quiet(t)
vc := simVCenter(t)
dead := VCenter{Name: "dead", URL: "https://127.0.0.1:1/", User: "u", Password: "p", Datacenter: "DC0"}
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc, dead}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("a failed server made the document unreadable: %v\n%s", err, out)
}
if len(doc.Failed) != 1 || !strings.Contains(doc.Failed[0], "dead") {
t.Errorf("failed = %v", doc.Failed)
}
if strings.Contains(out, "ERROR") {
t.Errorf("the failure was printed as prose as well:\n%s", out)
}
}
// The issues listing runs against a real inventory. The simulated machines have
// no VMware Tools, which is exactly the kind of thing it is for.
func TestSimIssuesListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
plain := stripEscapes(out)
if !strings.Contains(plain, "WHY") {
t.Errorf("the issues listing has no reason column:\n%s", plain)
}
if !strings.Contains(plain, "VMware Tools") {
t.Errorf("the running machines without Tools were not reported:\n%s", plain)
}
}
// The machine's own events, which is what '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)
}
}
+28 -27
View File
@@ -25,7 +25,7 @@ func snapList(vc VCenter, vmname string) error {
return err
}
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
@@ -35,7 +35,7 @@ func snapList(vc VCenter, vmname string) error {
}
PF("Snapshots for %s (%s):\n", Cwb(vm.Name()), vc.Name)
printSnapshots(tree, "")
printSnapshots(tree)
return nil
}
@@ -62,11 +62,11 @@ func snapNew(vc VCenter, vmname string) error {
}
PO("snapshot " + name + " created")
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
printSnapshots(tree, "")
printSnapshots(tree)
return nil
}
@@ -86,10 +86,23 @@ func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string
if err != nil {
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
}
if err := task.Wait(s.ctx); err != nil {
return fmt.Errorf("%s: the snapshot %s failed: %w", s.vc.Name, name, err)
// Bounded, like every other task gvm waits for. This one was not: it waited
// on the session's own context, which has no deadline, so a snapshot that
// vCenter never finished froze the interactive list with the screen mid-draw
// and no key being read — the one place where waiting for ever is worst.
return waitTask(s.ctx, task, snapshotWait, SF("snapshot %s of %s", name, vmName(s, ref)))
}
// vmName is the machine's name for a message, from its reference alone. Cheap:
// one property, and only asked for when something has gone wrong enough to be
// worth naming.
func vmName(s *session, ref types.ManagedObjectReference) string {
var mvm mo.VirtualMachine
if err := object.NewVirtualMachine(s.client.Client, ref).
Properties(s.ctx, ref, []string{"name"}, &mvm); err != nil {
return ref.Value
}
return nil
return mvm.Name
}
// snapRemove removes one snapshot, with its children left where they are, and
@@ -227,7 +240,7 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
return err
}
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
@@ -235,7 +248,7 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
P("no snapshots for", vm.Name())
return nil
}
printSnapshots(tree, "")
printSnapshots(tree)
ok, err := confirmDestructive(vc, SF("remove ALL snapshots of %s", vm.Name()),
[][2]string{{"machine", vm.Name()}},
@@ -253,24 +266,12 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
return nil
}
// snapshots reads the machine's snapshot tree, empty when it has none.
func snapshots(s *session, vm *object.VirtualMachine) ([]types.VirtualMachineSnapshotTree, error) {
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), []string{"snapshot"}, &mvm); err != nil {
return nil, fmt.Errorf("%s: cannot read the snapshots of %s: %w", s.vc.Name, vm.Name(), err)
}
if mvm.Snapshot == nil {
return nil, nil
}
return mvm.Snapshot.RootSnapshotList, nil
}
func printSnapshots(list []types.VirtualMachineSnapshotTree, indent string) {
for _, s := range list {
PF("%s|- %s (%s)\n", indent, s.Name, s.CreateTime.Local().Format("02.01.2006 15:04"))
if len(s.ChildSnapshotList) > 0 {
printSnapshots(s.ChildSnapshotList, indent+" ")
}
// printSnapshots writes the tree the interactive list draws, indented one step
// so it reads as a listing rather than as output. One drawing for all three
// places a snapshot tree appears.
func printSnapshots(entries []snapEntry) {
for _, e := range entries {
PF(" %s\n", e.line())
}
}
+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)
}
}
+98 -31
View File
@@ -11,35 +11,112 @@
package main
import (
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/methods"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// snapEntry is one snapshot of a machine, flattened out of the tree with its
// depth kept so the list can still show what descends from what.
// snapEntry is one snapshot of a machine, flattened out of the tree. The tree is
// what a snapshot list is *about* — which state descends from which — so each
// entry carries the drawing of its own branch rather than only a depth: that way
// the sheet, the picker and `snap -l` all show one and the same tree instead of
// three approximations of it.
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
current bool // the state the machine is running from
prefix string // the branch drawn in front of the name
current bool // the state the machine is running from
}
// label is the entry as one line: indented by depth, dated, and marked when it is
// the snapshot the machine is currently on.
func (e snapEntry) label() string {
s := SR(" ", e.depth) + e.name + " (" + e.created + ")"
// 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 {
s := e.prefix + e.name + " (" + e.created + ")"
if e.current {
s += " ← current"
}
return s
}
// The pieces of the tree. A child hangs off a branch; the last child closes it,
// and the run underneath either carries the line on or leaves the space blank.
const (
treeBranch = "├─ "
treeLast = "└─ "
treeCarry = "│ "
treeBlank = " "
)
// flattenSnapshots walks the snapshot tree into a list, parents before their
// children, drawing each branch as it goes. Root snapshots start flush left:
// they are the beginnings of separate lines of descent, not children of anything.
func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.ManagedObjectReference) []snapEntry {
var out []snapEntry
var walk func(nodes []types.VirtualMachineSnapshotTree, depth int, prefix string)
walk = func(nodes []types.VirtualMachineSnapshotTree, depth int, prefix string) {
for i, n := range nodes {
last := i == len(nodes)-1
branch, carry := treeBranch, treeCarry
if last {
branch, carry = treeLast, treeBlank
}
if depth == 0 { // a root hangs off nothing
branch, carry = "", ""
}
out = append(out, snapEntry{
ref: n.Snapshot,
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,
})
walk(n.ChildSnapshotList, depth+1, prefix+carry)
}
}
walk(roots, 0, "")
return out
}
// 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)
@@ -47,32 +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
}
// 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
}
current := types.ManagedObjectReference{}
if mvm.Snapshot.CurrentSnapshot != nil {
current = *mvm.Snapshot.CurrentSnapshot
if info.CurrentSnapshot != nil {
current = *info.CurrentSnapshot
}
var out []snapEntry
var walk func(list []types.VirtualMachineSnapshotTree, depth int)
walk = func(list []types.VirtualMachineSnapshotTree, depth int) {
for _, t := range list {
out = append(out, snapEntry{
ref: t.Snapshot,
name: t.Name,
desc: t.Description,
created: t.CreateTime.Local().Format("02.01.2006 15:04"),
depth: depth,
current: t.Snapshot == current,
})
walk(t.ChildSnapshotList, depth+1)
}
}
walk(mvm.Snapshot.RootSnapshotList, 0)
return out, nil
return flattenSnapshots(info.RootSnapshotList, current)
}
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
+374
View File
@@ -0,0 +1,374 @@
// sort.go — the order the table is in.
//
// One key opens a legend on the status line and the next key picks the order, so
// the list stays on screen while it rearranges itself in front of you. Ordinary
// letters cannot be used on their own: the list's filter swallows those.
//
// Every order has a natural direction, because that is what asking for it means.
// Sorting by name means a to z; sorting by processor load means the busiest
// first, and having to reverse it every time would be a nuisance dressed up as
// consistency. `r` reverses whatever is current.
package main
import (
"sort"
"strings"
)
// sortOrder is one way of arranging the table.
type sortOrder struct {
key rune // the letter that picks it
name string // what it is called, in the title and the legend
natural bool // its own direction: true means largest or busiest first
cmp func(a, b vmRow) int
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
var sortOrders = []sortOrder{
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
{key: 'p', name: "power", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(powerRank(a), powerRank(b)) }},
{key: 'c', name: "cpu load", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
{key: 'm', name: "memory in use", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key 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))
}},
{key: 'u', name: "processors", natural: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.NumCpu), int(b.vm.Summary.Config.NumCpu))
}},
{key: 'v', name: "vcenter", cmp: func(a, b vmRow) int { return cmpText(a.vc.Name, b.vc.Name) }},
{key: 'h', name: "host", cmp: func(a, b vmRow) int { return cmpText(a.host, b.host) }},
{key: 'a', name: "address", cmp: func(a, b vmRow) int { return cmpAddress(a, b) }},
}
// sortReverse is the one legend entry that is not an order of its own.
const sortReverse = 'r'
// powerRank puts a running machine above a suspended one above a stopped one, so
// that "by power" means what an operator means by it.
func powerRank(r vmRow) int {
switch r.powerShort() {
case "on":
return 3
case "susp":
return 2
case "off":
return 1
}
return 0
}
func cmpText(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }
func cmpInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// A value that is not there is not a small value. Wherever one can be missing —
// a load figure on a stopped machine, an address on a machine whose guest is not
// talking — the machine belongs at the *bottom* of the list, and which end of the
// comparison that is depends on which way the order naturally runs.
//
// Load runs downwards by nature (busiest first), so an unknown load has to
// compare as the smallest. An address runs upwards (a to z), so a missing address
// has to compare as the largest. Same rule, opposite polarity; the two functions
// below say so where it can be checked.
// cmpLoad orders two machines by a load figure, unknown lowest — which puts it
// last under the busiest-first direction this order is asked for with.
func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
x, xok := load(a)
y, yok := load(b)
switch {
case !xok && !yok:
return 0
case !xok:
return -1
case !yok:
return 1
case x < y:
return -1
case x > y:
return 1
}
return 0
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// cmpAddress orders by address, unknown highest — which puts it last under the
// a-to-z direction this order is asked for with.
func cmpAddress(a, b vmRow) int {
x, y := a.ip(), b.ip()
switch {
case x == "-" && y == "-":
return 0
case x == "-":
return 1
case y == "-":
return -1
}
return cmpText(x, y)
}
// ------------------------------------------------------------------ the sorting
func (b *browser) order() sortOrder { return sortOrders[b.sortBy] }
// applySort rearranges the rows and rebuilds what is on screen. The selection
// follows the machine it was on, which refilter already sees to.
//
// Machines that compare equal are left in name order, always ascending, whichever
// way the sort itself runs: a screen full of machines all at 0 % that reshuffles
// when the direction is flipped would look like the numbers had changed.
func (b *browser) applySort() {
sortRows(b.rows, b.sortBy, b.sortDesc)
b.refilter()
}
// sortRows is the sorting itself, without a browser: `gvm vm -l` orders the same
// rows the same way.
func sortRows(rows []vmRow, by int, desc bool) {
o := sortOrders[by]
sort.SliceStable(rows, func(i, j int) bool {
x, y := rows[i], rows[j]
n := o.cmp(x, y)
if desc {
n = -n
}
if n != 0 {
return n < 0
}
return cmpText(x.name, y.name) < 0
})
}
// findOrder resolves what was asked for on the command line — a letter or a name,
// as the legend spells them — to one of the orders. Empty means the first, which
// is by name.
func findOrder(s string) (int, error) {
if s == "" {
return 0, nil
}
for i, o := range sortOrders {
if s == string(o.key) || s == o.name || s == shortName(o.name) {
return i, nil
}
}
names := make([]string, len(sortOrders))
for i, o := range sortOrders {
names[i] = shortName(o.name)
}
return 0, errf("cannot sort by %q — try one of: %s", s, strings.Join(names, ", "))
}
// sortLabel is the order as the title shows it: which way, and by what.
func (b *browser) sortLabel() string {
arrow := "↑"
if b.sortDesc {
arrow = "↓"
}
return arrow + " " + b.order().name
}
// sortLegend is the choices, 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 {
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:], " "),
}
}
// shortName is the legend's spelling: the title has room for the whole name, one
// line shared with the status does not.
func shortName(name string) string {
switch name {
case "power":
return "pwr" // as the column is headed, and it keeps the legend inside 80
case "cpu load":
return "cpu%"
case "memory in use":
return "mem%"
case "memory size":
return "size"
case "processors":
return "cpus"
case "vcenter":
return "vc"
case "address":
return "ip"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
return name
}
// sortPrompt puts the legend up and waits for one key. Anything that is not a
// choice leaves the order alone: this is the one prompt in the list that is
// reached by accident, and doing nothing is the right answer to a stray key.
func (b *browser) sortPrompt() {
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
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
if k.special != keyRune {
return
}
if k.r == sortReverse {
b.sortDesc = !b.sortDesc
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
for i, o := range sortOrders {
if o.key == k.r {
b.sortBy, b.sortDesc = i, o.natural
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
}
}
// sortedColumn reports whether this column is the one the table is ordered by, so
// its heading can be lit up. Matching by name rather than by index keeps the two
// tables — the columns and the orders — free to be listed in different orders.
func (b *browser) sortedColumn(header string) bool {
switch b.order().name {
case "name":
return header == "NAME"
case "power":
return header == "PWR"
case "cpu load":
return header == "CPU%"
case "memory in use":
return header == "MEM%"
case "memory size":
return header == "MEM"
case "processors":
return header == "CPU"
case "vcenter":
return header == "VC"
case "host":
return header == "HOST"
case "address":
return header == "IP"
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"io"
"os"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sortRow builds a machine with the few figures the orders compare.
func sortRow(name, vc, host, ip string, cpu, memMB int32, mhz, memUse int32, on bool) vmRow {
state := types.VirtualMachinePowerStatePoweredOff
if on {
state = types.VirtualMachinePowerStatePoweredOn
}
r := vmRow{
vc: VCenter{Name: vc},
ref: types.ManagedObjectReference{Value: "vm-" + name},
name: name,
host: host,
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{Name: name, NumCpu: cpu, MemorySizeMB: memMB},
Runtime: types.VirtualMachineRuntimeInfo{
PowerState: state, MaxCpuUsage: 1000,
},
QuickStats: types.VirtualMachineQuickStats{
OverallCpuUsage: mhz, GuestMemoryUsage: memUse,
},
}},
}
if ip != "" {
r.vm.Guest = &types.GuestInfo{IpAddress: ip}
}
return r
}
// sortFixture: four machines chosen so that every order puts them in a different
// sequence, and one of them is stopped so the "unknown load" rule is exercised.
func sortFixture() []vmRow {
return []vmRow{
// name vc host ip cpu mem mhz memuse on
sortRow("delta", "v108", "esx2", "10.0.0.4", 2, 4096, 100, 2048, true), // cpu 10%, mem 50%
sortRow("alpha", "v308", "esx1", "10.0.0.1", 8, 16384, 900, 1638, true), // cpu 90%, mem 10%
sortRow("charlie", "v38", "esx3", "", 1, 2048, 500, 1536, true), // cpu 50%, mem 75%
sortRow("bravo", "v308", "esx1", "10.0.0.2", 4, 8192, 0, 0, false), // stopped
}
}
func order(t *testing.T, key rune, desc bool) []string {
t.Helper()
b := &browser{rows: sortFixture()}
for i, o := range sortOrders {
if o.key == key {
b.sortBy, b.sortDesc = i, desc
}
}
b.applySort()
out := make([]string, len(b.rows))
for i, r := range b.rows {
out[i] = r.name
}
return out
}
func TestSortOrders(t *testing.T) {
for _, c := range []struct {
key rune
desc bool
want string
note string
}{
{'n', false, "alpha bravo charlie delta", "by name, a to z"},
{'n', true, "delta charlie bravo alpha", "by name, reversed"},
// The busiest first, and the machine that is not running has no load at
// all, so it goes last however loud the others are.
{'c', true, "alpha charlie delta bravo", "by cpu load, busiest first"},
{'c', false, "bravo delta charlie alpha", "by cpu load, quietest first"},
{'m', true, "charlie delta alpha bravo", "by memory in use, fullest first"},
{'s', true, "alpha bravo delta charlie", "by memory size: 16G 8G 4G 2G"},
{'u', true, "alpha bravo delta charlie", "by processors, most first"},
// Running before stopped, and equal machines stay in name order.
{'p', true, "alpha charlie delta bravo", "by power, running first"},
{'v', false, "delta alpha bravo charlie", "by vcenter: v108 v308 v308 v38, as text"},
{'h', false, "alpha bravo delta charlie", "by host"},
// A machine with no address has not got a small one: it goes last, in the
// a-to-z direction this order is asked for with.
{'a', false, "alpha bravo delta charlie", "by address, the addressless last"},
} {
if got := strings.Join(order(t, c.key, c.desc), " "); got != c.want {
t.Errorf("%s:\n got %s\n want %s", c.note, got, c.want)
}
}
}
// Machines that compare equal keep name order whichever way the sort runs. A
// screen full of machines all at 0 % that reshuffled when the direction flipped
// would look as though the numbers had changed.
func TestEqualMachinesKeepNameOrder(t *testing.T) {
rows := []vmRow{
sortRow("zulu", "v308", "esx1", "", 1, 1024, 0, 0, false),
sortRow("mike", "v308", "esx1", "", 1, 1024, 0, 0, false),
sortRow("alpha", "v308", "esx1", "", 1, 1024, 0, 0, false),
}
for _, desc := range []bool{false, true} {
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
for i, o := range sortOrders {
if o.key == 'c' {
b.sortBy = i
}
}
b.applySort()
var got []string
for _, r := range b.rows {
got = append(got, r.name)
}
if strings.Join(got, " ") != "alpha mike zulu" {
t.Errorf("reversed=%v: equal machines came out %v", desc, got)
}
}
}
// Picking an order takes its own direction with it: nobody asking for the
// processor load wants the idle machines first.
func TestEachOrderHasItsOwnDirection(t *testing.T) {
for _, o := range sortOrders {
switch o.name {
case "name", "vcenter", "host", "address":
if o.natural {
t.Errorf("%s reads downwards by default", o.name)
}
default:
if !o.natural {
t.Errorf("%s does not put the largest first by default", o.name)
}
}
}
}
// The prompt: a letter picks an order, r reverses, and anything else leaves the
// table as it was — it is the one prompt in the list reached by accident.
func TestSortPrompt(t *testing.T) {
newBrowser := func() (*browser, func(string)) {
b := &browser{rows: sortFixture()}
b.applySort()
screen, keys, _ := headlessBrowser(t)
b.tty, b.keys = screen.tty, screen.keys
return b, func(s string) { keys.WriteString(s); b.sortPrompt() }
}
b, press := newBrowser()
press("c")
if b.order().name != "cpu load" || !b.sortDesc {
t.Errorf("c gave %q desc=%v, want cpu load busiest first", b.order().name, b.sortDesc)
}
if b.rows[0].name != "alpha" {
t.Errorf("the busiest machine is not first: %s", b.rows[0].name)
}
press("r")
if !strings.Contains(b.sortLabel(), "↑") || b.rows[0].name != "bravo" {
t.Errorf("r did not reverse: %q, first %s", b.sortLabel(), b.rows[0].name)
}
before := b.order().name
press("\x1b") // Esc
if b.order().name != before {
t.Errorf("Esc changed the order to %q", b.order().name)
}
press("Z") // not a choice
if b.order().name != before {
t.Errorf("a stray letter changed the order to %q", b.order().name)
}
if b.prompt != nil {
t.Error("the legend stayed on screen")
}
}
// The title says which order the table is in, the heading of that column is lit,
// and exactly one column is.
func TestSortIsVisibleInTheTable(t *testing.T) {
b := &browser{rows: sortFixture()}
for i, o := range sortOrders {
b.sortBy, b.sortDesc = i, o.natural
b.applySort()
if !strings.Contains(b.sortLabel(), o.name) {
t.Errorf("the title says %q for the %s order", b.sortLabel(), o.name)
}
arrow := "↑"
if o.natural {
arrow = "↓"
}
if !strings.HasPrefix(b.sortLabel(), arrow) {
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
}
// In the table the order belongs to: sorting by what is wrong with a
// machine lights the reason column, which only the issues listing has.
table := listColumns(b.rows, o.name == "issues")
lit := 0
for _, c := range table {
if b.sortedColumn(c.header) {
lit++
}
}
if lit != 1 {
t.Errorf("the %s order lights %d column headings, want exactly 1", o.name, lit)
}
}
}
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
lines := sortLegend(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:\n%s", string(o.key), o.name, legend)
}
}
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
t.Errorf("the legend does not offer the reverse:\n%s", legend)
}
// Every line of it fits a terminal of eighty. The legend has the bottom two
// rows to itself, and they begin at the left edge rather than behind the
// pointer's gutter, so the budget is eighty whole — but a line over it would
// be truncated, and the choices it hid would be unreachable in the only
// place they are offered.
for i, line := range lines {
if n := len([]rune(line)); n > 80 {
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
}
}
// Two rows, and not three: there are only two to spare.
if len(lines) > 2 {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// 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.
seen := map[rune]bool{sortReverse: true}
for _, o := range sortOrders {
if seen[o.key] {
t.Errorf("%q is the letter for more than one order", string(o.key))
}
seen[o.key] = true
}
}
// The sort legend is a menu, not a question: it must not be dressed as a warning
// and must not have the yes/no hint hung on it, which is what happened while the
// two shared one string.
func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
b := &browser{rows: sortFixture()}
b.applySort()
screen, keys, cleanup := headlessBrowser(t)
defer cleanup()
b.tty, b.keys = screen.tty, screen.keys
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "16")
// Render the legend by asking for it and reading the frame the prompt puts up,
// with the keystroke that answers it queued behind.
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
b.tty = w
keys.WriteString("n")
done := make(chan string, 1)
go func() {
out, _ := io.ReadAll(r)
done <- string(out)
}()
b.sortPrompt()
w.Close()
frame := stripEscapes(<-done)
if !strings.Contains(frame, "sort:") {
t.Fatalf("the legend was not drawn:\n%s", frame)
}
if strings.Contains(frame, "y = yes") {
t.Error("the yes/no hint was hung on the sort legend")
}
for _, line := range strings.Split(frame, "\r\n") {
if strings.Contains(line, "sort:") && len([]rune(line)) > 100 {
t.Errorf("the legend line is %d columns wide: %q", len([]rune(line)), line)
}
}
}
// By how many snapshots a machine is carrying. Nought is a figure here and not
// a missing one — nothing to clean up is a fact about the machine — so it sorts
// where nought belongs: at the bottom going down, at the top coming back up.
func TestSortBySnapshotCount(t *testing.T) {
rows := []vmRow{
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
}
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, '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)
}
}
+17 -230
View File
@@ -1,126 +1,26 @@
// ======================================================================================= go toolbox (mwx'2026)
//
// What gvm actually uses. The toolbox this came from carries a good deal more —
// base62 encoders, a mysql row reader, viper and gjson wrappers, an ip-range
// access check — and none of it was ever called here. Carried along, it was three
// dependencies and a page of code that nothing exercised and no test covered,
// including one access check that had never been wired up and would have panicked
// on the first malformed entry in its list. It is gone; the toolbox it belongs to
// still has it.
package main
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"flag"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/eknkc/basex"
"github.com/tidwall/gjson"
)
var tbversion = "0.6.0"
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
// Updating oneself lives in selfupdate.go now: releases of a Gitea instance,
// fetched over TLS and probed before they replace the running binary. What used
// to be here downloaded a binary over plain http and applied it unverified.
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
addrs, err := net.InterfaceAddrs() // once, not once per configured network
if err != nil {
PE("Error getting addresses")
os.Exit(1)
}
match := 0
for _, validnet := range NETS {
_, ipNet, err := net.ParseCIDR(validnet)
if err != nil { // a typo in the list is a typo, not a nil dereference
PE("bad network " + validnet)
continue
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
if ipNet.Contains(ipnet.IP) {
match++
}
}
}
}
}
if match == 0 {
PE("access violation, permission denied")
os.Exit(1)
}
}
func Enc(str string) string { // ----------------------------------------------------------------- encode string
enc, err := basex.NewEncoding(Db64(CHRS))
if err != nil {
return ""
}
return enc.Encode([]byte(Rndstr(2) + str))
}
func Dec(str string) string { // ----------------------------------------------------------------- decode string
enc, err := basex.NewEncoding(Db64(CHRS))
if err != nil {
return ""
}
b, err := enc.Decode(str)
if err != nil || len(b) < 2 { // the 2 bytes are the Rndstr(2) prefix Enc puts in front
return ""
}
return string(b[2:])
}
func Db64(txt string) string { // ------------------------------------------------------------- string to base64
d, _ := base64.StdEncoding.DecodeString(txt)
return string(d)
}
func Rndstr(n int) string { // ------------------------------------------------------- random string with length
b := make([]rune, n)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(LR))))
b[i] = LR[n.Int64()]
}
return string(b)
}
func Input(msg string, def string) string { // -------------------------------- AlecAivazis/survey: input string
tmp := ""
err := survey.AskOne(&survey.Input{Message: msg, Default: def}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
tmp := ""
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
if overwrite {
@@ -149,14 +49,16 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
}
}
func Getid(n int) string { // --------------------------------------------------------- get base62 random string
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ret := make([]byte, n)
for i := 0; i < n; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
ret[i] = letters[num.Int64()]
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
tmp := ""
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return string(ret)
return tmp
}
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
@@ -169,70 +71,6 @@ func GETid(n int) string { // --------------------------------------------------
return string(ret)
}
func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table
defer rows.Close()
columns, _ := rows.Columns()
count := len(columns)
tableData := make([]map[string]interface{}, 0)
values := make([]interface{}, count)
valuePtrs := make([]interface{}, count)
for rows.Next() {
for i := 0; i < count; i++ {
valuePtrs[i] = &values[i]
}
rows.Scan(valuePtrs...)
entry := make(map[string]interface{})
for i, col := range columns {
var v interface{}
val := values[i]
b, ok := val.([]byte)
if ok {
v = string(b)
} else {
v = val
}
entry[col] = v
}
tableData = append(tableData, entry)
}
return (tableData)
}
func Checkip(network string, ip string) bool { // ---------- check if ip is in range (cidr address or single ip)
if net.ParseIP(ip) == nil {
return false
}
_, subnet, err := net.ParseCIDR(network)
if err == nil {
if subnet.Contains(net.ParseIP(ip)) {
return true
}
} else {
if network == ip {
return true
}
}
return false
}
func Isflagpassed(name string) bool { // -------------------------------------------------- check if flag is set
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func Body(r *http.Response) string { // ------------------------------------------------------------ http body
body, err := io.ReadAll(r.Body)
if err == nil {
return string(body)
}
return ""
}
func Atoi(s string) int { // ------------------------------------------------------------------------------ atoi
i, err := strconv.Atoi(s)
if err != nil {
@@ -245,14 +83,6 @@ func Itoa(i int) string { // ---------------------------------------------------
return strconv.Itoa(i)
}
func GJA(j string, k string) []string { // -------------------------------------------------- convert gjson array
var ret []string
for _, c := range gjson.Get(j, k).Array() {
ret = append(ret, c.String())
}
return ret
}
// ------------------------------------------------------------------------------------------------ print simple
func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Stdout, a...) }
@@ -276,51 +106,8 @@ func PO(msg ...string) (n int, err error) {
// errf is fmt.Errorf under a shorter name, to go with the P/PF/SF family.
func errf(format string, a ...any) error { return fmt.Errorf(format, a...) }
// ------------------------------------------------------------------------------------------ regular expression
func ReplaceFirst(re *regexp.Regexp, str, replace string) string {
loc := re.FindStringIndex(str)
if loc == nil {
return str
}
return str[:loc[0]] + replace + str[loc[1]:]
}
// -------------------------------------------------------------------------------------------- string functions
func Shortstr(s string, length int) string {
runes := []rune(s)
if len(runes) <= length {
return s
}
return string(runes[:length-2]) + ".."
}
func SR(str string, n int) string { return strings.Repeat(str, n) }
func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array
// A new slice, not slice[:0]: filtering in place writes through the caller's
// backing array, so the caller's own variable is left holding shifted,
// duplicated entries — and it looks like a pure function at the call site.
result := make([]string, 0, len(slice))
for _, v := range slice {
if v != target {
result = append(result, v)
}
}
return result
}
// -------------------------------------------------------------------------------------------- system functions
func prgname() string { // program name
exepath, err := os.Executable()
if err != nil {
PE(SF("Error getting executable path: %s", err))
return ""
}
exename := filepath.Base(exepath)
return exename
}
// ========================================================================================================= END
+49 -13
View File
@@ -83,8 +83,10 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlO
keyCtrlR
keyCtrlS
keyCtrlW
keyCtrlC
keyEsc
)
@@ -136,10 +138,17 @@ func (kr *keyReader) next() key {
return key{special: keyCtrlC}
case 0x01:
return key{special: keyCtrlA}
case 0x0f:
return key{special: keyCtrlO}
case 0x12:
return key{special: keyCtrlR}
case 0x13:
return key{special: keyCtrlS}
// ^w, and not the ^i the mnemonic wants: Ctrl-I *is* Tab (0x09), which the
// list already moves down with, so an issues filter bound to it would have
// scrolled the table instead.
case 0x17:
return key{special: keyCtrlW}
case 0x1b:
return kr.readEscape()
case '\r', '\n':
@@ -183,11 +192,39 @@ func (kr *keyReader) readEscape() key {
if b2 != '[' {
return key{special: keyEsc}
}
b3, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
// Read the whole sequence before deciding what it was. A control sequence is
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
// parameters there are depends on the key *and* on which modifiers were held:
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
//
// Reading a fixed number of bytes instead — one, and for the digits one more
// for the "~" — left the rest of a longer sequence in the stream, where the
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
// tilde in it, having first jumped to the top of the list.
params := make([]byte, 0, 8)
var final byte
for {
b, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
}
if b >= 0x40 && b <= 0x7e {
final = b
break
}
if len(params) < cap(params) {
params = append(params, b)
}
}
switch b3 {
// Only the unmodified keys are answered. A sequence with modifiers, or one
// this does not know, is swallowed whole and ignored — which is the point:
// what must not happen is for half of it to arrive as text.
if len(params) > 0 && final != '~' {
return key{special: keyNone}
}
switch final {
case 'A':
return key{special: keyUp}
case 'B':
@@ -202,22 +239,21 @@ func (kr *keyReader) readEscape() key {
return key{special: keyEnd}
case 'Z':
return key{special: keyShiftTab}
case '1', '3', '4', '5', '6':
kr.readByte() // consume trailing '~'
switch b3 {
case '1':
case '~':
switch string(params) {
case "1", "7":
return key{special: keyHome}
case '3':
case "3":
return key{special: keyDelete}
case '4':
case "4", "8":
return key{special: keyEnd}
case '5':
case "5":
return key{special: keyPgUp}
case '6':
case "6":
return key{special: keyPgDn}
}
}
return key{special: keyEsc}
return key{special: keyNone}
}
func utf8SeqLen(b byte) int {
+84 -4
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 @@
0.1.21
1.1.7
+107 -86
View File
@@ -1,110 +1,131 @@
// vm.go — the virtual machine listing.
// vm.go — the printed machine listing.
//
// The same columns, cells and colours as the interactive list, from the same
// table: `gvm vm -l` and `gvm vm` used to disagree about the very same machine,
// the printed one showing a bare 32 where the other showed 32.0MB, and lacking
// the power state, the host and both load figures entirely. There is one
// definition of what a row of machines looks like (browse.go) and both views
// render it.
package main
import (
"fmt"
"regexp"
"sort"
"sync"
"github.com/fatih/color"
)
// lsvm lists the virtual machines of every server it is given, sorted by name
// across all of them. One unreachable vCenter is a warning and not the end: the
// old version called log.Fatal inside the loop, which threw away everything the
// servers before it had already answered.
//
// The servers are asked at the same time. Three logins one after another are
// three round trips to three machines, and there is nothing to be gained by
// waiting for each in turn.
func lsvm(targets []VCenter, match string) error {
re, err := regexp.Compile("(?i)" + match)
// lsOptions is what one printed listing was asked for. A struct rather than six
// arguments in a row: three of them are booleans, and a call site reading
// (rows, "", false, true, false) says nothing about which is which.
type lsOptions struct {
match string // regexp on the machine's name
orderBy string // one of sortOrders, by letter or by name
reverse bool
issues bool // only the machines with something wrong with them
json bool // as a document instead of a table
}
// lsvm prints the machines of every server it is given. One unreachable vCenter
// is a line of complaint, not the end of the listing.
func lsvm(targets []VCenter, opt lsOptions) error {
re, err := regexp.Compile("(?i)" + opt.match)
if err != nil {
return fmt.Errorf("bad pattern %q: %w", match, err)
return errf("bad pattern %q: %w", opt.match, err)
}
by, err := findOrder(opt.orderBy)
if err != nil {
return err
}
const (
xfmt = "%-32s %-8s %4s %-15s %3d %8d\n"
xfmts = "%-32s %-8s %4s %-15s %3s %8s\n"
)
PF(xfmts, "VM NAME", "ID", "VM", "IP", "CPU", "MEMORY")
PF(xfmts, SR("=", 32), SR("=", 8), SR("=", 4), SR("=", 15), SR("=", 3), SR("=", 8))
rows := make([][]string, len(targets))
errs := make([]error, len(targets))
var wg sync.WaitGroup
for i, vc := range targets {
wg.Add(1)
go func(i int, vc VCenter) {
defer wg.Done()
rows[i], errs[i] = listOne(vc, re, xfmt)
}(i, vc)
}
wg.Wait()
var all []string
failed := 0
for i := range targets {
if errs[i] != nil {
PE(errs[i].Error())
failed++
continue
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
// In a table the failures are said before it, where they will not be
// scrolled past. In a document they belong *in* it: a line of prose in the
// middle of the JSON would break whatever is reading it, and a script that
// cannot tell "no machines" from "the server did not answer" is a script
// that reports an empty cluster.
if !opt.json {
for _, why := range found.failed {
PE(why)
}
all = append(all, rows[i]...)
}
if err != nil {
return err
}
sort.Strings(all)
for _, line := range all {
PN(line)
rows := make([]vmRow, 0, len(found.rows))
for _, r := range found.rows {
if re.MatchString(r.name) {
rows = append(rows, r)
}
}
if opt.issues {
rows = withIssues(rows)
}
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
// Nothing at all came back: that is a failure of the command, not a listing
// that happens to be empty, and the exit status should say so.
if failed == len(targets) {
return fmt.Errorf("no vCenter answered")
if opt.json {
return printJSON(found, rows)
}
// Nothing to report, and nobody watching: say nothing at all. This is the
// listing that belongs in cron, and cron mails whatever a command prints —
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
// one morning it did not arrive would mean nothing either. On a terminal
// somebody is waiting for an answer, so there it is said.
if opt.issues && len(rows) == 0 {
if !color.NoColor {
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
}
return nil
}
printList(rows, listColumns(rows, opt.issues))
return nil
}
// listOne collects the formatted lines of a single server.
func listOne(vc VCenter, re *regexp.Regexp, xfmt string) ([]string, error) {
s, err := connect(vc)
if err != nil {
return nil, err
}
defer s.close()
// printList writes the table. On a terminal it is fitted to the width, the same
// as the interactive one; into a pipe every column is written and every column is
// made as wide as the longest thing in it.
//
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
// machine names off at twenty-two characters — silently, with an ellipsis, into
// output whose whole purpose is to be read by something else.
func printList(rows []vmRow, table []browseColumn) {
cols := fitColumnsOf(table, printWidth(table))
vms, err := s.vms("summary", "guest")
if err != nil {
return nil, err
pcs := make([]printColumn, len(cols))
for i, c := range cols {
pcs[i] = printColumn{header: c.header, width: c.width}
}
var out []string
for _, vm := range vms {
name := vm.Summary.Config.Name
if name == "" || !re.MatchString(name) {
continue
body := make([][]cell, 0, len(rows))
for _, r := range rows {
cells := make([]cell, len(cols))
for i, c := range cols {
col := ""
if c.color != nil {
col = c.color(r)
}
cells[i] = cell{text: c.cell(r), col: col}
}
// Both of these are optional in vSphere and absent for a machine that
// is being created or deleted while the listing runs — reading them
// unguarded is a nil dereference at exactly the wrong moment.
ip := "-"
if vm.Guest != nil && vm.Guest.IpAddress != "" {
ip = vm.Guest.IpAddress
}
id := "-"
if vm.Summary.Vm != nil {
id = vm.Summary.Vm.Value
}
out = append(out, SF(xfmt,
Shortstr(name, 32),
id,
vc.Name,
ip,
vm.Summary.Config.NumCpu,
vm.Summary.Config.MemorySizeMB))
body = append(body, cells)
}
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
widen(pcs, body)
}
printRow(pcs, "", nil)
for _, cells := range body {
printRow(pcs, "", cells)
}
return out, nil
}
// printWidth is what the table is laid out for. color.NoColor is the answer to
// "is this a terminal" that the colours already go by, so the two cannot disagree
// about where the output is headed.
func printWidth(table []browseColumn) int {
if color.NoColor {
return tableWidth(table)
}
cols, _ := termSize()
return max(cols, 20)
}