Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba15b7897 | ||
|
|
4a5477bde7 | ||
|
|
137a799399 | ||
|
|
b902059402 | ||
|
|
dda9dc1e74 | ||
|
|
d3fa1790a1 | ||
|
|
9b0d218174 | ||
|
|
1c14902d7c | ||
|
|
321c69ebaa | ||
|
|
0558e42f2b | ||
|
|
5335e585d2 | ||
|
|
bb4124961c |
@@ -1,16 +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
|
||||
|
||||
@@ -30,29 +36,74 @@ 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 -R <vm>` | remove all of them |
|
||||
| `snap --revert <vm> -s <snap>` | put the machine back to that snapshot |
|
||||
| `snap --removeall <vm>` | remove all of them |
|
||||
| `power -o <vm>` | power on |
|
||||
| `power -s <vm>` | ask the guest to shut down (needs VMware Tools) |
|
||||
| `power -b <vm>` | ask the guest to reboot (needs VMware Tools) |
|
||||
| `power --off <vm>` | power off at the hypervisor — hard |
|
||||
| `power --reset <vm>` | reset at the hypervisor — hard |
|
||||
| `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
|
||||
|
||||
@@ -65,17 +116,185 @@ 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
|
||||
^s take a snapshot of the selected machine: a name, then a
|
||||
confirmation
|
||||
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
|
||||
|
||||
`^s` is the only key that changes anything, and it takes two steps.
|
||||
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
|
||||
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 `^a` or `^s` in the table says so rather than doing nothing visible.
|
||||
|
||||
`^s` takes two steps.
|
||||
|
||||
First the name. The field starts empty and its hint says which random name Enter
|
||||
alone would use — the same kind `snap -n` gives — so `^s enter y` is the quick
|
||||
@@ -88,19 +307,302 @@ 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.
|
||||
|
||||
Nothing else here writes: no key powers a machine on or off, removes a snapshot
|
||||
or touches a setting.
|
||||
### The sheet's four letters
|
||||
|
||||
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 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. It is everything that changes a
|
||||
machine, and it lives there and nowhere else — the list is arrowed through and its
|
||||
filter swallows every ordinary letter, so a hotkey that powered a machine off
|
||||
would sit one fumbled control key away from an outage, and the sheet has to be
|
||||
opened first anyway.
|
||||
|
||||
Above the choices the menu repeats the few lines of the sheet the choice depends
|
||||
on — state, guest, hostname, address — taken from the sheet itself, so the two
|
||||
cannot word the same fact differently. On a terminal too short for both, those
|
||||
lines go one at a time, least useful first: the state stays longest because every
|
||||
choice depends on it, then the address and the hostname, which say which machine
|
||||
this is about.
|
||||
|
||||
n take a snapshot o power on
|
||||
r revert to a snapshot ... s shut down the guest
|
||||
d remove a snapshot ... b reboot the guest
|
||||
D remove ALL snapshots S power off (hard)
|
||||
B reset (hard)
|
||||
|
||||
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
|
||||
always needs the shift key. What cannot be done right now is greyed out with the
|
||||
reason next to it — "no VMware Tools", "already running", "no snapshots" — rather
|
||||
than left out, and picking it anyway spells the reason out instead of running it.
|
||||
|
||||
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
|
||||
machine may share a name, and a rollback point is not something to identify by a
|
||||
string that is not unique.
|
||||
|
||||
Then comes a full page: the machine, the vCenter, the datacenter, the host, the
|
||||
state, and what the operation costs — and it asks for **`YES`, in capitals**.
|
||||
Not `y`, not `yes`, not Enter. A word that needs the shift key cannot be given by
|
||||
a hand resting on Enter, and in the list the machine is whatever the cursor
|
||||
happens to be on, so the page above the prompt is the part that matters.
|
||||
|
||||
Powering *on* is the single exception, and ends at one `y`: it destroys nothing.
|
||||
|
||||
Reverting asks vCenter not to start the machine again afterwards, so a machine
|
||||
whose snapshot was taken while it ran does not come back up with a rewound disk
|
||||
while you are still reading the message. Afterwards gvm reads the power state
|
||||
back and reports what it actually is rather than what it asked for.
|
||||
|
||||
After an operation the machine's row is read again, so the list shows what
|
||||
happened rather than what the last sweep found. For a power on or off that means
|
||||
waiting for vCenter's own view to catch up first: the task finishes a moment
|
||||
before the property collector agrees, and a row re-read in between would still
|
||||
show the old state. Opening the menu re-reads too, which is what picks up a guest
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
Removing snapshots asks first. `-y` answers in advance, which is what cron
|
||||
needs; without a terminal and without `-y` the answer is no, so an unattended
|
||||
run cannot delete anything by accident.
|
||||
### The dangerous half
|
||||
|
||||
`snap --revert`, `snap --removeall`, `power --off` and `power --reset` stop or
|
||||
rewind a running machine, and the first of them destroys data outright. Four
|
||||
things hold for all of them:
|
||||
|
||||
* **Graceful and hard are separate commands.** `power -s` asks the guest's
|
||||
operating system to shut down; `power --off` cuts the power at the hypervisor.
|
||||
gvm never turns the first into the second because VMware Tools did not answer
|
||||
— it refuses, and names the hard variant so you choose it deliberately.
|
||||
* **The destructive options have no short letter.** `--revert`, `--removeall`,
|
||||
`--off` and `--reset` have to be spelled out; only the harmless ones (`-o`,
|
||||
`-s`, `-b`, `-l`, `-n`) are one keystroke.
|
||||
* **Nothing impossible is sent.** A machine that is already off is not shut down
|
||||
again, and a graceful operation on a machine without Tools is refused before
|
||||
anything reaches vCenter. `-y` does not override this.
|
||||
* **They ask first, and fail closed.** Each prints what will happen, to which
|
||||
machine, on which vCenter, and asks — defaulting to no. `-y` answers in
|
||||
advance, which is what cron needs. Without a terminal *and* without `-y` the
|
||||
command refuses with exit status 1 rather than doing nothing quietly: a script
|
||||
that gets "nothing done" and exit 0 would believe the machine was stopped.
|
||||
|
||||
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
|
||||
|
||||
@@ -110,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.
|
||||
@@ -139,18 +667,67 @@ 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,
|
||||
the parameter sheet and the palette are checked on synthetic machines, and
|
||||
everything that talks to a server — logging in, the inventory sweep, the
|
||||
snapshot round trip, `^s` with its name field and its confirmation, the error
|
||||
snapshot round trip, `^s` with its name field and its confirmation, the whole
|
||||
power and revert half with its confirmations declined and then given, the error
|
||||
paths — runs
|
||||
against govmomi's own simulator, started inside the test process (see
|
||||
`sim_test.go`). The configured vCenters are production; no test goes near them.
|
||||
`sim_test.go`). The configured vCenters are production; no test goes near them,
|
||||
and none of them reads `~/.gvmrc`.
|
||||
|
||||
The checks that stand between a keystroke and a machine are tested on their own
|
||||
as well: the whole power matrix (state × VMware Tools × operation), that a
|
||||
refused operation sends nothing, that an unavailable menu entry does not run when
|
||||
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.
|
||||
|
||||
+854
@@ -0,0 +1,854 @@
|
||||
// actions.go — everything in the interactive list that changes a machine.
|
||||
//
|
||||
// One door: ^a opens a menu of what can be done to the selected machine, and
|
||||
// nothing here can be reached by a single keystroke from the list. That is
|
||||
// deliberate. The list is arrowed through, the filter swallows every ordinary
|
||||
// letter, and a hotkey that powered a machine off would sit one fumbled control
|
||||
// key away from a production outage.
|
||||
//
|
||||
// The steps between a keystroke and a machine are, in order:
|
||||
//
|
||||
// ^a the menu, for the machine under the cursor
|
||||
// a letter the operation — lowercase asks the guest, uppercase does it at
|
||||
// the hypervisor, so the violent variant needs the shift key
|
||||
// (a picker) for revert and remove: which snapshot exactly, by reference
|
||||
// a full page what will happen, to which machine, on which vCenter
|
||||
// the name typed out, for everything that stops or rewinds a machine
|
||||
//
|
||||
// Powering a machine *on* is the one operation that ends at a single y: it
|
||||
// destroys nothing, and the worst case — the wrong machine started — is undone
|
||||
// by the menu it came from.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// menuItem is one line of the action menu. why, when set, is the reason the
|
||||
// operation cannot be carried out on this machine right now — the item is still
|
||||
// shown, greyed out, because "why can I not shut this down" is a question worth
|
||||
// answering on the spot rather than by omission.
|
||||
type menuItem struct {
|
||||
key rune
|
||||
label string
|
||||
why string // the whole reason, shown on the status line when it is picked
|
||||
hint string // the short one, shown in the menu's own column
|
||||
run func(*browser, vmRow)
|
||||
}
|
||||
|
||||
func (m menuItem) available() bool { return m.why == "" && m.run != nil }
|
||||
|
||||
// separator is a menu line that is only a rule.
|
||||
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
|
||||
// list that was true a moment ago rather than one from the last full sweep.
|
||||
func (b *browser) openMenu() {
|
||||
r := b.current()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.sess == nil {
|
||||
b.setStatus(colErr, "no connection to "+r.vc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// The row is re-read first. What the menu offers is decided from the
|
||||
// machine's state, and a state from the last full sweep is old enough to
|
||||
// matter: it would offer to power off a machine whose guest has meanwhile
|
||||
// finished shutting down. A failure here is not fatal — the menu is simply
|
||||
// built from what is known — but it is said.
|
||||
if err := b.refreshRow(); err != nil {
|
||||
b.setStatus(colWarn, "showing what was last read: "+err.Error())
|
||||
}
|
||||
r = b.current()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
snaps, err := snapshotsOf(r.sess, r.ref)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
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.menuInfo, b.menuSel = nil, nil, nil, 0
|
||||
}
|
||||
|
||||
// buildMenu is the menu for one machine in its current state.
|
||||
func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
noSnaps, noSnapsHint := "", ""
|
||||
if len(snaps) == 0 {
|
||||
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
|
||||
}
|
||||
|
||||
// pwr builds a power entry, asking the same check that runs again immediately
|
||||
// before anything is sent.
|
||||
pwr := func(key rune, label string, op powerOp) menuItem {
|
||||
short, long := powerObjection(r, op)
|
||||
return menuItem{key: key, label: label, why: long, hint: short,
|
||||
run: func(b *browser, r vmRow) { b.power(r, op) }}
|
||||
}
|
||||
|
||||
return []menuItem{
|
||||
{key: 'n', label: "take a snapshot",
|
||||
run: func(b *browser, r vmRow) { b.closeMenu(); b.snapshot() }},
|
||||
{key: 'r', label: "revert to a snapshot ...", why: noSnaps, hint: noSnapsHint,
|
||||
run: func(b *browser, r vmRow) { b.openPicker(r, pickRevert) }},
|
||||
{key: 'd', label: "remove a snapshot ...", why: noSnaps, hint: noSnapsHint,
|
||||
run: func(b *browser, r vmRow) { b.openPicker(r, pickRemove) }},
|
||||
{key: 'D', label: "remove ALL snapshots", why: noSnaps, hint: noSnapsHint,
|
||||
run: func(b *browser, r vmRow) { b.removeAll(r, len(snaps)) }},
|
||||
separator(),
|
||||
pwr('o', "power on", opPowerOn),
|
||||
pwr('s', "shut down the guest", opShutdownGuest),
|
||||
pwr('b', "reboot the guest", opRebootGuest),
|
||||
separator(),
|
||||
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
|
||||
pwr('B', "reset (hard, at the hypervisor)", opReset),
|
||||
}
|
||||
}
|
||||
|
||||
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
|
||||
// do the same for anyone who would rather read than remember.
|
||||
func (b *browser) menuKey(k key) {
|
||||
switch k.special {
|
||||
case keyEsc, keyLeft:
|
||||
b.closeMenu()
|
||||
return
|
||||
case keyUp, keyShiftTab:
|
||||
b.menuMove(-1)
|
||||
return
|
||||
case keyDown, keyTab:
|
||||
b.menuMove(1)
|
||||
return
|
||||
case keyEnter:
|
||||
if b.menuSel >= 0 && b.menuSel < len(b.menu) {
|
||||
b.runMenuItem(b.menu[b.menuSel])
|
||||
}
|
||||
return
|
||||
case keyRune:
|
||||
for _, m := range b.menu {
|
||||
if m.key == k.r && !m.isSeparator() {
|
||||
b.runMenuItem(m)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMenuItem carries out one entry, or says why it cannot.
|
||||
func (b *browser) runMenuItem(m menuItem) {
|
||||
if m.isSeparator() {
|
||||
return
|
||||
}
|
||||
if !m.available() {
|
||||
b.setStatus(colWarn, m.why)
|
||||
return
|
||||
}
|
||||
r := b.current()
|
||||
if r == nil {
|
||||
b.closeMenu()
|
||||
return
|
||||
}
|
||||
m.run(b, *r)
|
||||
}
|
||||
|
||||
// menuMove steps over the separators, which cannot be selected.
|
||||
func (b *browser) menuMove(step int) {
|
||||
for i := b.menuSel + step; i >= 0 && i < len(b.menu); i += step {
|
||||
if !b.menu[i].isSeparator() {
|
||||
b.menuSel = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- the snapshot picker
|
||||
|
||||
// pickKind is what the picker is picking for. It decides the wording and what
|
||||
// happens to the snapshot that is chosen.
|
||||
type pickKind int
|
||||
|
||||
const (
|
||||
pickRevert pickKind = iota
|
||||
pickRemove
|
||||
)
|
||||
|
||||
type picker struct {
|
||||
kind pickKind
|
||||
row vmRow
|
||||
items []snapEntry
|
||||
sel int
|
||||
scroll int
|
||||
}
|
||||
|
||||
func (b *browser) openPicker(r vmRow, kind pickKind) {
|
||||
if len(b.menuSnaps) == 0 {
|
||||
b.setStatus(colWarn, r.name+" has no snapshots")
|
||||
return
|
||||
}
|
||||
b.pick = &picker{kind: kind, row: r, items: b.menuSnaps}
|
||||
}
|
||||
|
||||
func (b *browser) closePicker() { b.pick = nil }
|
||||
|
||||
func (b *browser) pickerKey(k key) {
|
||||
p := b.pick
|
||||
switch k.special {
|
||||
case keyEsc, keyLeft:
|
||||
b.closePicker()
|
||||
case keyUp, keyShiftTab:
|
||||
p.sel = max(p.sel-1, 0)
|
||||
case keyDown, keyTab:
|
||||
p.sel = min(p.sel+1, len(p.items)-1)
|
||||
case keyHome:
|
||||
p.sel = 0
|
||||
case keyEnd:
|
||||
p.sel = len(p.items) - 1
|
||||
case keyEnter:
|
||||
if p.sel < 0 || p.sel >= len(p.items) {
|
||||
return
|
||||
}
|
||||
entry := p.items[p.sel]
|
||||
kind, row := p.kind, p.row
|
||||
b.closePicker()
|
||||
b.closeMenu()
|
||||
if kind == pickRevert {
|
||||
b.revert(row, entry)
|
||||
return
|
||||
}
|
||||
b.removeOne(row, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the actions
|
||||
|
||||
// revert puts the machine back to one snapshot. This is the most destructive
|
||||
// thing gvm can do — everything written since that snapshot is gone, and unlike a
|
||||
// removed snapshot there is nothing left to go back to.
|
||||
func (b *browser) revert(r vmRow, e snapEntry) {
|
||||
warn := "everything written since then is lost, and cannot be recovered"
|
||||
if e.current {
|
||||
warn = "this is the snapshot the machine is already running from; " +
|
||||
"everything written since it was taken is lost"
|
||||
}
|
||||
ok := b.confirmYES(confirmation{
|
||||
headline: "revert " + r.name + " to " + e.name,
|
||||
row: r,
|
||||
facts: [][2]string{
|
||||
{"snapshot", e.name},
|
||||
{"taken", e.created},
|
||||
{"machine state", r.powerLong()},
|
||||
},
|
||||
consequence: warn,
|
||||
note: "gvm asks for the machine not to be started again afterwards",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.working(SF("reverting %s to %s ...", r.name, e.name))
|
||||
if err := revertToSnapshot(r.sess, e.ref, SF("%s of %s", e.name, r.name)); err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
// The state is read back rather than asserted: gvm asks for the machine not
|
||||
// to be started again, and then says what the server actually reports.
|
||||
b.done(SF("%s reverted to %s — %s", r.name, e.name, powerNow(r)))
|
||||
}
|
||||
|
||||
// removeOne removes a single snapshot. Its children stay and are re-parented.
|
||||
func (b *browser) removeOne(r vmRow, e snapEntry) {
|
||||
ok := b.confirmYES(confirmation{
|
||||
headline: "remove snapshot " + e.name + " of " + r.name,
|
||||
row: r,
|
||||
facts: [][2]string{
|
||||
{"snapshot", e.name},
|
||||
{"taken", e.created},
|
||||
},
|
||||
consequence: "this rollback point is gone; the machine keeps running as it is",
|
||||
note: "the disks are consolidated afterwards, which can take a long time",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.working(SF("removing %s of %s, consolidating ...", e.name, r.name))
|
||||
if err := removeSnapshot(r.sess, e.ref, SF("%s of %s", e.name, r.name)); err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.done(SF("snapshot %s of %s removed", e.name, r.name))
|
||||
}
|
||||
|
||||
// removeAll removes every snapshot the machine has.
|
||||
func (b *browser) removeAll(r vmRow, n int) {
|
||||
b.closeMenu()
|
||||
ok := b.confirmYES(confirmation{
|
||||
headline: SF("remove all %d snapshots of %s", n, r.name),
|
||||
row: r,
|
||||
facts: [][2]string{{"snapshots", Itoa(n)}},
|
||||
consequence: "every rollback point of this machine is gone; " +
|
||||
"the machine keeps running as it is",
|
||||
note: "the disks are consolidated afterwards, which can take a long time",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.working(SF("removing all snapshots of %s, consolidating ...", r.name))
|
||||
if err := removeAllSnapshots(r.sess, r.ref, r.name); err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.done(SF("all snapshots of %s removed", r.name))
|
||||
}
|
||||
|
||||
// power starts, stops or reboots the machine. Powering on is the only operation
|
||||
// that ends at a single y; the other four stop a running machine and want its
|
||||
// name typed out.
|
||||
func (b *browser) power(r vmRow, op powerOp) {
|
||||
b.closeMenu()
|
||||
spec := op.spec()
|
||||
|
||||
if op == opPowerOn {
|
||||
if !b.ask(SF("power on %s on %s?", r.name, r.vc.Name)) {
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ok := b.confirmYES(confirmation{
|
||||
headline: spec.verb + " " + r.name,
|
||||
row: r,
|
||||
facts: [][2]string{{"machine state", r.powerLong()}},
|
||||
consequence: spec.consequence,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
b.working(SF("%s: %s ...", r.name, spec.verb))
|
||||
msg, err := runPower(r.sess, r, op)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.done(msg)
|
||||
}
|
||||
|
||||
// working puts a line on the screen before an operation that will block the loop
|
||||
// — a vCenter task can take minutes, and a terminal that goes silent for that
|
||||
// long looks like a hang.
|
||||
func (b *browser) working(msg string) {
|
||||
b.setStatus(colWarn, msg)
|
||||
b.render()
|
||||
}
|
||||
|
||||
// done reports success and refreshes what is on screen, so the list shows the new
|
||||
// power state and an open sheet the new snapshots.
|
||||
//
|
||||
// A re-read that fails is said out loud rather than swallowed: the operation
|
||||
// worked, and the one thing the operator must not conclude from a row that still
|
||||
// says "on" is that nothing happened.
|
||||
func (b *browser) done(msg string) {
|
||||
err := b.refreshRow()
|
||||
if b.detail != nil {
|
||||
b.openDetail()
|
||||
}
|
||||
if err != nil {
|
||||
b.setStatus(colWarn, msg+" — the row could not be re-read (^r): "+err.Error())
|
||||
return
|
||||
}
|
||||
b.setStatus(colInfo, msg)
|
||||
}
|
||||
|
||||
// powerNow reads the machine's power state from the server, in words, for a
|
||||
// message that should report rather than assume. An unreadable state is said to
|
||||
// be unknown instead of guessed at.
|
||||
func powerNow(r vmRow) string {
|
||||
if r.sess == nil {
|
||||
return "state unknown"
|
||||
}
|
||||
state, err := object.NewVirtualMachine(r.sess.client.Client, r.ref).PowerState(r.sess.ctx)
|
||||
if err != nil {
|
||||
return "state unknown"
|
||||
}
|
||||
return vmRow{vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
|
||||
Runtime: types.VirtualMachineRuntimeInfo{PowerState: state}}}}.powerLong()
|
||||
}
|
||||
|
||||
// refreshRow re-reads the machine that was just acted on. Only that one: the
|
||||
// point is to show what just happened, not to pay for another full sweep.
|
||||
func (b *browser) refreshRow() error {
|
||||
if b.sel < 0 || b.sel >= len(b.view) {
|
||||
return nil
|
||||
}
|
||||
r := &b.rows[b.view[b.sel]]
|
||||
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, 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
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the confirmation
|
||||
|
||||
// confirmation is the full page shown before anything destructive. It is a page
|
||||
// and not a line at the bottom of the list on purpose: the machine, the vCenter
|
||||
// and the consequence have to be readable at a glance by someone who has been
|
||||
// arrowing through a list of two hundred machines.
|
||||
type confirmation struct {
|
||||
headline string
|
||||
row vmRow
|
||||
facts [][2]string
|
||||
consequence string
|
||||
note string
|
||||
}
|
||||
|
||||
// confirmWord is what has to be typed out before anything destructive happens.
|
||||
// Capitals, and compared exactly: a word that needs the shift key cannot be given
|
||||
// by a hand resting on Enter, and it is short enough not to tempt anyone into
|
||||
// typing it before they have read the page it is on.
|
||||
const confirmWord = "YES"
|
||||
|
||||
const confirmHelp = "type " + confirmWord + " to confirm, esc to abandon"
|
||||
|
||||
// confirmYES shows the page and waits for that word. Nothing else will do: not
|
||||
// y, not yes in lower case, not Enter on its own.
|
||||
func (b *browser) confirmYES(c confirmation) bool {
|
||||
b.confirm = &c
|
||||
defer func() { b.confirm = nil }()
|
||||
|
||||
typed, ok := b.input("type " + confirmWord + " to confirm: ")
|
||||
if !ok || typed == "" {
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return false
|
||||
}
|
||||
if typed != confirmWord {
|
||||
b.setStatus(colWarn, SF("%q is not %s — nothing done", typed, confirmWord))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- rendering
|
||||
|
||||
func (b *browser) renderMenu() {
|
||||
cols, rows := termSize()
|
||||
r := b.current()
|
||||
if r == nil {
|
||||
b.closeMenu()
|
||||
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, " "},
|
||||
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))})
|
||||
used++
|
||||
continue
|
||||
}
|
||||
pointer, keyCol, labelCol := " ", colInfo, colValue
|
||||
if !m.available() {
|
||||
keyCol, labelCol = colDim, colDim
|
||||
}
|
||||
if i == b.menuSel {
|
||||
pointer = "▸ "
|
||||
}
|
||||
line := []seg{
|
||||
{colPointer, pointer},
|
||||
{keyCol, string(m.key) + " "},
|
||||
{labelCol, padRight(m.label, 38)},
|
||||
}
|
||||
if m.hint != "" {
|
||||
line = append(line, seg{colDim, m.hint})
|
||||
}
|
||||
segLine(&sb, cols, line...)
|
||||
used++
|
||||
}
|
||||
|
||||
for ; used < rows-2; used++ {
|
||||
sb.WriteString(scrEOL + "\r\n")
|
||||
}
|
||||
if b.status != "" {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
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())
|
||||
}
|
||||
|
||||
func (b *browser) renderPicker() {
|
||||
cols, rows := termSize()
|
||||
p := b.pick
|
||||
|
||||
what := "revert to which snapshot"
|
||||
if p.kind == pickRemove {
|
||||
what = "remove which snapshot"
|
||||
}
|
||||
|
||||
visible := max(rows-5, 1)
|
||||
if p.sel < p.scroll {
|
||||
p.scroll = p.sel
|
||||
}
|
||||
if p.sel >= p.scroll+visible {
|
||||
p.scroll = p.sel - visible + 1
|
||||
}
|
||||
end := min(p.scroll+visible, len(p.items))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(scrClear + scrHide)
|
||||
segLine(&sb, cols, seg{colTitle, what + " of " + p.row.name},
|
||||
seg{colDim, " " + p.row.vc.Name})
|
||||
segLine(&sb, cols)
|
||||
|
||||
for i := p.scroll; i < end; i++ {
|
||||
e := p.items[i]
|
||||
pointer, col := " ", colRow
|
||||
if i == p.sel {
|
||||
pointer, col = "▸ ", colRowSel
|
||||
}
|
||||
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.line()})
|
||||
}
|
||||
for i := end - p.scroll; i < visible; i++ {
|
||||
sb.WriteString(scrEOL + "\r\n")
|
||||
}
|
||||
|
||||
if b.status != "" {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
segLine(&sb, cols, seg{colDim, "nothing happens yet — the next screen asks to confirm"})
|
||||
}
|
||||
sb.WriteString(colDim + truncate("↑/↓ choose ⏎ continue esc back", cols) + attrOff + scrEOL)
|
||||
b.write(sb.String())
|
||||
}
|
||||
|
||||
// renderConfirm is the last screen before something irreversible. It is written
|
||||
// to be read, not skimmed: one headline, the facts under each other, the
|
||||
// consequence in the colour of an error, and the name to type at the bottom.
|
||||
func (b *browser) renderConfirm() {
|
||||
cols, rows := termSize()
|
||||
c := b.confirm
|
||||
width := min(cols-4, 76)
|
||||
|
||||
// The page is collected first and padded from what it actually holds. It used
|
||||
// to count its own lines with a constant, which was wrong by five: the typing
|
||||
// line ended up five rows above the bottom while the cursor went to the
|
||||
// bottom, and the two were visibly apart.
|
||||
body := [][]seg{
|
||||
{{colErr + attrBold, "⚠ " + c.headline}},
|
||||
nil,
|
||||
}
|
||||
facts := append([][2]string{
|
||||
{"machine", c.row.name},
|
||||
{"vCenter", c.row.vc.Name + " " + c.row.vc.URL},
|
||||
{"datacenter", c.row.vc.Datacenter},
|
||||
{"host", c.row.host},
|
||||
}, c.facts...)
|
||||
for _, f := range facts {
|
||||
body = append(body, []seg{
|
||||
{colLabel, padRight(" "+f[0], labelWidth)},
|
||||
{colValue, f[1]},
|
||||
})
|
||||
}
|
||||
body = append(body, nil)
|
||||
for _, l := range wrap(c.consequence, width) {
|
||||
body = append(body, []seg{{colErr, " " + l}})
|
||||
}
|
||||
if c.note != "" {
|
||||
body = append(body, nil)
|
||||
for _, l := range wrap(c.note, width) {
|
||||
body = append(body, []seg{{colDim, " " + l}})
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(scrClear + scrHide)
|
||||
for i, l := range body {
|
||||
if i >= rows-2 {
|
||||
break // a terminal too short for the whole page: the warning comes first
|
||||
}
|
||||
segLine(&sb, cols, l...)
|
||||
}
|
||||
for used := len(body); used < rows-2; used++ {
|
||||
sb.WriteString(scrEOL + "\r\n")
|
||||
}
|
||||
|
||||
if b.edit != nil {
|
||||
b.editLine(&sb, cols)
|
||||
} else {
|
||||
segLine(&sb, cols)
|
||||
}
|
||||
sb.WriteString(colDim + truncate(confirmHelp, cols) + attrOff + scrEOL)
|
||||
b.parkCursor(&sb, cols, rows)
|
||||
b.write(sb.String())
|
||||
}
|
||||
|
||||
// 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:
|
||||
flush()
|
||||
line = w
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// stateRow is a machine in a given power state with a given VMware Tools status,
|
||||
// for the checks that decide whether an operation may be offered at all.
|
||||
func stateRow(state types.VirtualMachinePowerState, tools string) vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.PowerState = state
|
||||
r.vm.Guest.ToolsRunningStatus = tools
|
||||
return r
|
||||
}
|
||||
|
||||
const (
|
||||
toolsUp = string(types.VirtualMachineToolsRunningStatusGuestToolsRunning)
|
||||
toolsDown = string(types.VirtualMachineToolsRunningStatusGuestToolsNotRunning)
|
||||
)
|
||||
|
||||
// The whole matrix of what may be done to a machine. This is the check that
|
||||
// stands between a menu entry and a production machine, and every one of these
|
||||
// answers is a decision not to send something to vCenter.
|
||||
func TestCheckPowerMatrix(t *testing.T) {
|
||||
on := types.VirtualMachinePowerStatePoweredOn
|
||||
off := types.VirtualMachinePowerStatePoweredOff
|
||||
susp := types.VirtualMachinePowerStateSuspended
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
state types.VirtualMachinePowerState
|
||||
tools string
|
||||
op powerOp
|
||||
allowed bool
|
||||
}{
|
||||
{"power on a stopped machine", off, toolsDown, opPowerOn, true},
|
||||
{"power on a suspended machine", susp, toolsDown, opPowerOn, true},
|
||||
{"power on one already running", on, toolsUp, opPowerOn, false},
|
||||
|
||||
{"shut down with tools", on, toolsUp, opShutdownGuest, true},
|
||||
{"shut down without tools", on, toolsDown, opShutdownGuest, false},
|
||||
{"shut down a stopped machine", off, toolsUp, opShutdownGuest, false},
|
||||
{"shut down a suspended machine", susp, toolsUp, opShutdownGuest, false},
|
||||
|
||||
{"reboot with tools", on, toolsUp, opRebootGuest, true},
|
||||
{"reboot without tools", on, toolsDown, opRebootGuest, false},
|
||||
{"reboot a stopped machine", off, toolsUp, opRebootGuest, false},
|
||||
|
||||
// The hard ones go through the hypervisor, so Tools is irrelevant — but
|
||||
// the machine still has to be running for them to mean anything.
|
||||
{"power off a running machine", on, toolsDown, opPowerOff, true},
|
||||
{"power off a stopped machine", off, toolsDown, opPowerOff, false},
|
||||
{"reset a running machine", on, toolsDown, opReset, true},
|
||||
{"reset a stopped machine", off, toolsUp, opReset, false},
|
||||
} {
|
||||
err := checkPower(stateRow(c.state, c.tools), c.op)
|
||||
if c.allowed && err != nil {
|
||||
t.Errorf("%s: refused with %q", c.name, err)
|
||||
}
|
||||
if !c.allowed && err == nil {
|
||||
t.Errorf("%s: allowed, want refused", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A graceful operation refused for want of Tools has to name the hard variant.
|
||||
// Otherwise the operator is left to find "power off" on their own, at exactly the
|
||||
// moment they are in a hurry.
|
||||
func TestToolsRefusalNamesTheHardVariant(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
op powerOp
|
||||
want string
|
||||
}{
|
||||
{opShutdownGuest, "power off"},
|
||||
{opRebootGuest, "reset"},
|
||||
} {
|
||||
err := checkPower(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), c.op)
|
||||
if err == nil {
|
||||
t.Fatalf("%v was allowed without Tools", c.op)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("the refusal does not offer %q: %s", c.want, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "web01") {
|
||||
t.Errorf("the refusal does not name the machine: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools that is busy running scripts is still Tools: refusing a shutdown then
|
||||
// would send the operator to the hard variant for no reason.
|
||||
func TestToolsExecutingScriptsCounts(t *testing.T) {
|
||||
r := stateRow(types.VirtualMachinePowerStatePoweredOn,
|
||||
string(types.VirtualMachineToolsRunningStatusGuestToolsExecutingScripts))
|
||||
if !r.toolsRunning() {
|
||||
t.Error("Tools executing scripts does not count as running")
|
||||
}
|
||||
if err := checkPower(r, opShutdownGuest); err != nil {
|
||||
t.Errorf("shutdown refused while Tools runs scripts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsFallsBackToTheSummary(t *testing.T) {
|
||||
r := stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown)
|
||||
r.vm.Guest = nil
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: toolsUp}
|
||||
if !r.toolsRunning() {
|
||||
t.Error("the Tools status in the summary was not read")
|
||||
}
|
||||
r.vm.Summary.Guest = nil // nothing at all known
|
||||
if r.toolsRunning() {
|
||||
t.Error("a machine with no guest information claims Tools is running")
|
||||
}
|
||||
}
|
||||
|
||||
// The menu is built from those checks, so what cannot be done is visibly greyed
|
||||
// out with the reason — and, more to the point, cannot be run.
|
||||
func TestMenuAvailability(t *testing.T) {
|
||||
b := &browser{}
|
||||
find := func(items []menuItem, k rune) menuItem {
|
||||
for _, m := range items {
|
||||
if m.key == k {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("no menu entry %q", string(k))
|
||||
return menuItem{}
|
||||
}
|
||||
someSnaps := []snapEntry{{name: "s1", created: "01.01.2026 00:00"}}
|
||||
|
||||
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps)
|
||||
for _, k := range []rune{'n', 'r', 'd', 'D', 's', 'b', 'S', 'B'} {
|
||||
if !find(running, k).available() {
|
||||
t.Errorf("%q not offered for a running machine with Tools: %s", string(k), find(running, k).why)
|
||||
}
|
||||
}
|
||||
if find(running, 'o').available() {
|
||||
t.Error("power on is offered for a machine that is already running")
|
||||
}
|
||||
|
||||
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps)
|
||||
for _, k := range []rune{'s', 'b'} {
|
||||
if find(noTools, k).available() {
|
||||
t.Errorf("%q offered without VMware Tools", string(k))
|
||||
}
|
||||
}
|
||||
for _, k := range []rune{'S', 'B'} {
|
||||
if !find(noTools, k).available() {
|
||||
t.Errorf("the hard %q is not offered without Tools, though it does not need it", string(k))
|
||||
}
|
||||
}
|
||||
|
||||
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps)
|
||||
if !find(stopped, 'o').available() {
|
||||
t.Error("power on is not offered for a stopped machine")
|
||||
}
|
||||
for _, k := range []rune{'s', 'b', 'S', 'B'} {
|
||||
if find(stopped, k).available() {
|
||||
t.Errorf("%q offered for a machine that is not running", string(k))
|
||||
}
|
||||
}
|
||||
|
||||
// Without snapshots there is nothing to revert to or remove.
|
||||
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil)
|
||||
for _, k := range []rune{'r', 'd', 'D'} {
|
||||
if find(bare, k).available() {
|
||||
t.Errorf("%q offered for a machine with no snapshots", string(k))
|
||||
}
|
||||
}
|
||||
if !find(bare, 'n').available() {
|
||||
t.Error("taking a snapshot is not offered for a machine with none")
|
||||
}
|
||||
}
|
||||
|
||||
// An unavailable entry must not run when it is picked anyway — by its letter or
|
||||
// by Enter. The greying out is a courtesy; this is the actual guard.
|
||||
func TestUnavailableMenuItemDoesNotRun(t *testing.T) {
|
||||
ran := false
|
||||
b := &browser{
|
||||
rows: []vmRow{stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown)},
|
||||
view: []int{0},
|
||||
menu: []menuItem{{key: 's', label: "shut down the guest", why: "not running",
|
||||
run: func(*browser, vmRow) { ran = true }}},
|
||||
}
|
||||
b.runMenuItem(b.menu[0])
|
||||
if ran {
|
||||
t.Fatal("an unavailable entry ran")
|
||||
}
|
||||
if !strings.Contains(b.status, "not running") {
|
||||
t.Errorf("the reason was not shown: %q", b.status)
|
||||
}
|
||||
|
||||
b.menuKey(key{special: keyRune, r: 's'})
|
||||
if ran {
|
||||
t.Fatal("an unavailable entry ran when its letter was pressed")
|
||||
}
|
||||
}
|
||||
|
||||
// Separators are drawn, never selected, and never run.
|
||||
func TestMenuSkipsSeparators(t *testing.T) {
|
||||
b := &browser{menu: []menuItem{
|
||||
{key: 'a', label: "one", run: func(*browser, vmRow) {}},
|
||||
separator(),
|
||||
{key: 'b', label: "two", run: func(*browser, vmRow) {}},
|
||||
}}
|
||||
b.menuMove(1)
|
||||
if b.menuSel != 2 {
|
||||
t.Errorf("moving down landed on %d, want the entry past the separator", b.menuSel)
|
||||
}
|
||||
b.menuMove(-1)
|
||||
if b.menuSel != 0 {
|
||||
t.Errorf("moving up landed on %d", b.menuSel)
|
||||
}
|
||||
b.menuMove(1)
|
||||
b.menuMove(1) // past the end: stays put rather than falling off
|
||||
if b.menuSel != 2 {
|
||||
t.Errorf("moving past the end landed on %d", b.menuSel)
|
||||
}
|
||||
b.runMenuItem(separator()) // must be a no-op, not a nil call
|
||||
}
|
||||
|
||||
// The confirmation. Nothing but YES, in capitals, may pass it.
|
||||
func TestConfirmYESWantsExactlyThatWord(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
typed string
|
||||
want bool
|
||||
note string
|
||||
}{
|
||||
{"YES\r", true, "the word itself"},
|
||||
{" YES \r", true, "the word with stray spaces, which input() trims"},
|
||||
{"yes\r", false, "lower case, which needs no shift key"},
|
||||
{"Yes\r", false, "capitalised, not capitals"},
|
||||
{"YE\r", false, "a prefix"},
|
||||
{"YESS\r", false, "one letter too many"},
|
||||
{"\r", false, "Enter on its own"},
|
||||
{"y\r", false, "y, which is enough for the harmless questions"},
|
||||
{"YES\x1b", false, "the word, then Esc"},
|
||||
} {
|
||||
b, keys, cleanup := headlessBrowser(t)
|
||||
keys.WriteString(c.typed)
|
||||
got := b.confirmYES(confirmation{
|
||||
headline: "power off web01",
|
||||
row: testRow("web01", true, "10.0.0.5"),
|
||||
consequence: "cuts the power",
|
||||
})
|
||||
cleanup()
|
||||
if got != c.want {
|
||||
t.Errorf("%s: confirmYES gave %v, want %v", c.note, got, c.want)
|
||||
}
|
||||
if !got && !strings.Contains(b.status, "nothing done") {
|
||||
t.Errorf("%s: a refusal left the status %q", c.note, b.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards: the confirmation page counted its own lines with a
|
||||
// constant, put the line being typed into five rows above the bottom, and sent
|
||||
// the cursor to the bottom anyway — so the cursor sat several rows below the
|
||||
// text it belonged to. The two have to end up on the same row for every shape of
|
||||
// page, which is why this walks a few of them.
|
||||
func TestConfirmPageParksTheCursorOnTheTypingLine(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "24")
|
||||
const rows = 24
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
conf confirmation
|
||||
}{
|
||||
{"the shortest page", confirmation{
|
||||
headline: "power off web01", row: testRow("web01", true, "10.0.0.5"),
|
||||
consequence: "cuts the power"}},
|
||||
{"with a note", confirmation{
|
||||
headline: "remove snapshot s1 of web01", row: testRow("web01", true, "10.0.0.5"),
|
||||
facts: [][2]string{{"snapshot", "s1"}, {"taken", "01.01.2026 00:00"}},
|
||||
consequence: "this rollback point is gone; the machine keeps running as it is",
|
||||
note: "the disks are consolidated afterwards, which can take a long time"}},
|
||||
{"a consequence long enough to wrap several times", confirmation{
|
||||
headline: "revert web01 to s1", row: testRow("web01", true, "10.0.0.5"),
|
||||
consequence: strings.Repeat("everything written since then is lost and cannot be recovered. ", 4)}},
|
||||
} {
|
||||
frame, editRow, cursorRow, cursorCol := renderConfirmFrame(t, c.conf, "YE")
|
||||
|
||||
if editRow != rows-1 {
|
||||
t.Errorf("%s: the typing line is on row %d, want %d", c.name, editRow, rows-1)
|
||||
}
|
||||
if cursorRow != editRow {
|
||||
t.Errorf("%s: the cursor is on row %d, the typing line on %d", c.name, cursorRow, editRow)
|
||||
}
|
||||
want := len([]rune("type YES to confirm: ")) + len("YE") + 1
|
||||
if cursorCol != want {
|
||||
t.Errorf("%s: the cursor is at column %d, want %d", c.name, cursorCol, want)
|
||||
}
|
||||
if !strings.Contains(frame, "⚠") {
|
||||
t.Errorf("%s: the warning fell off the page", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderConfirmFrame draws one confirmation page and picks out where the typing
|
||||
// line and the terminal cursor ended up. Rows are 1-based, as the escape is.
|
||||
func renderConfirmFrame(t *testing.T, c confirmation, typed string) (frame string, editRow, cursorRow, cursorCol int) {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
b := &browser{tty: w, confirm: &c, edit: &editor{label: "type " + confirmWord + " to confirm: "}}
|
||||
typeInto(b.edit, typed)
|
||||
b.renderConfirm()
|
||||
w.Close()
|
||||
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame = string(raw)
|
||||
|
||||
for i, l := range strings.Split(frame, "\r\n") {
|
||||
if strings.Contains(stripEscapes(l), "type "+confirmWord+" to confirm:") {
|
||||
editRow = i + 1 // the first line of the frame is row 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if m := regexp.MustCompile(`\x1b\[(\d+);(\d+)H`).FindStringSubmatch(frame); m != nil {
|
||||
cursorRow, cursorCol = Atoi(m[1]), Atoi(m[2])
|
||||
}
|
||||
return frame, editRow, cursorRow, cursorCol
|
||||
}
|
||||
|
||||
// headlessBrowser gives a browser whose keyboard is a pipe and whose screen is
|
||||
// /dev/null, so the interaction can be driven from a test.
|
||||
func headlessBrowser(t *testing.T) (b *browser, keys *os.File, cleanup func()) {
|
||||
t.Helper()
|
||||
screen, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b = &browser{tty: screen, keys: newKeyReader(r)}
|
||||
return b, w, func() { w.Close(); screen.Close() }
|
||||
}
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
got := wrap("the quick brown fox jumps over the lazy dog", 12)
|
||||
for _, l := range got {
|
||||
if len([]rune(l)) > 12 {
|
||||
t.Errorf("line too long: %q", l)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != "the quick brown fox jumps over the lazy dog" {
|
||||
t.Errorf("wrapping lost or added words: %q", got)
|
||||
}
|
||||
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
|
||||
// come from the same judgement — a menu that greys something out for one reason
|
||||
// and then explains a different one is worse than no explanation.
|
||||
func TestMenuHintAndReasonAgree(t *testing.T) {
|
||||
b := &browser{}
|
||||
for _, r := range []vmRow{
|
||||
stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp),
|
||||
stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown),
|
||||
stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown),
|
||||
stateRow(types.VirtualMachinePowerStateSuspended, toolsUp),
|
||||
} {
|
||||
for _, m := range b.buildMenu(r, nil) {
|
||||
if m.isSeparator() {
|
||||
continue
|
||||
}
|
||||
if (m.why == "") != (m.hint == "") {
|
||||
t.Errorf("%q: reason %q and hint %q disagree about availability",
|
||||
m.label, m.why, m.hint)
|
||||
}
|
||||
// The hint has to fit the menu's column; the long one is a sentence.
|
||||
if len([]rune(m.hint)) > 20 {
|
||||
t.Errorf("%q: the hint is %d characters, too long for the column: %q",
|
||||
m.label, len([]rune(m.hint)), m.hint)
|
||||
}
|
||||
if m.why != "" && len([]rune(m.why)) > 100 {
|
||||
t.Errorf("%q: the reason is %d characters and will be truncated: %q",
|
||||
m.label, len([]rune(m.why)), m.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The command line's confirmation, and the distinction that keeps a script from
|
||||
// believing it stopped a machine: saying no is an answer, not being able to ask
|
||||
// is an error.
|
||||
func TestConfirmFailsClosedWithoutATerminal(t *testing.T) {
|
||||
ok, err := confirm("go ahead?", true)
|
||||
if !ok || err != nil {
|
||||
t.Errorf("-y did not answer the question: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
if haveTerminal() == nil {
|
||||
t.Skip("this test needs to run without a terminal; there is one here")
|
||||
}
|
||||
ok, err = confirm("go ahead?", false)
|
||||
if ok {
|
||||
t.Fatal("a question that could not be asked was treated as a yes")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("a question that could not be asked passed silently — a script would " +
|
||||
"read the exit status as success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-y") {
|
||||
t.Errorf("the error does not say how to run this unattended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Which state each operation ends in. Getting this wrong would mean waiting for
|
||||
// a state that never comes, and reporting a successful power off as unconfirmed.
|
||||
func TestPowerOperationsSettleWhereTheyShould(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
op powerOp
|
||||
want types.VirtualMachinePowerState
|
||||
waits bool
|
||||
}{
|
||||
{opPowerOn, types.VirtualMachinePowerStatePoweredOn, true},
|
||||
{opPowerOff, types.VirtualMachinePowerStatePoweredOff, true},
|
||||
{opReset, "", false}, // begins and ends powered on
|
||||
{opShutdownGuest, "", false}, // the guest answers in its own time
|
||||
{opRebootGuest, "", false},
|
||||
} {
|
||||
got, waits := c.op.settlesAt()
|
||||
if waits != c.waits {
|
||||
t.Errorf("%s: waits=%v, want %v", c.op.spec().verb, waits, c.waits)
|
||||
}
|
||||
if waits && got != c.want {
|
||||
t.Errorf("%s: waits for %q, want %q", c.op.spec().verb, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
+1070
-17
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -0,0 +1,359 @@
|
||||
// complete.go — shell completion, and the inventory cache behind it.
|
||||
//
|
||||
// The names one types at gvm are machine names, and they are long, and there
|
||||
// are hundreds of them on three servers. Completing them is what makes the
|
||||
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
|
||||
// be done by asking the vCenters: a shell completion runs on every Tab and has
|
||||
// to answer in milliseconds, and three logins take seconds.
|
||||
//
|
||||
// So it answers out of what gvm last saw. Every sweep of the machine list
|
||||
// leaves the names behind in the cache directory, per vCenter and with the time
|
||||
// on them, and `--complete-vms` reads that file and nothing else. The cache is
|
||||
// therefore always exactly as fresh as the last time somebody looked at the
|
||||
// list — which is the right currency for a Tab key, and no currency at all for
|
||||
// anything that acts on a machine. Nothing else in gvm reads this file: every
|
||||
// command resolves the name it was given against the server itself.
|
||||
//
|
||||
// `gvm config` says how old it is, because a completion that quietly offers a
|
||||
// machine deleted last month is a small mystery worth being able to explain.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
// completionFlagNames are the options answered before the flag parser, the same
|
||||
// way the update options are (gvm.go). They are what the generated scripts call
|
||||
// on every Tab, so they must work on a machine whose configuration is broken —
|
||||
// and must never print anything but the candidates. Nobody types them, which is
|
||||
// why they are deliberately not in the help.
|
||||
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
|
||||
|
||||
// isCompletionFlag reports whether this argument is one of them, so that the
|
||||
// help can be checked against what is actually answered — the same guard the
|
||||
// update options have (see the tests).
|
||||
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
|
||||
|
||||
// completionFlags answers those options and reports whether it did.
|
||||
func completionFlags() bool {
|
||||
args := os.Args[1:]
|
||||
for i, a := range args {
|
||||
if !contains(completionFlagNames, a) {
|
||||
continue
|
||||
}
|
||||
// The word after the option, skipping the "--" the completion scripts
|
||||
// put in front of it so that a prefix beginning with a dash cannot be
|
||||
// taken for an option of gvm's own.
|
||||
rest := ""
|
||||
for _, a := range args[i+1:] {
|
||||
if a == "--" {
|
||||
continue
|
||||
}
|
||||
rest = a
|
||||
break
|
||||
}
|
||||
switch a {
|
||||
case "--complete-vms":
|
||||
for _, name := range cachedNames(rest) {
|
||||
P(name)
|
||||
}
|
||||
case "--complete-vcenters":
|
||||
for _, name := range cachedVCenters() {
|
||||
P(name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the cache
|
||||
|
||||
// inventoryPath is where the names are kept: the cache directory, beside the
|
||||
// update note, and never in the configuration — losing it costs one Tab that
|
||||
// offers nothing.
|
||||
func inventoryPath() (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
|
||||
}
|
||||
|
||||
// cacheEntry is one machine as the cache remembers it.
|
||||
type cacheEntry struct {
|
||||
vc string
|
||||
when time.Time
|
||||
name string
|
||||
}
|
||||
|
||||
// saveInventory writes the machines of the servers that answered.
|
||||
//
|
||||
// The servers that did not are left exactly as they were: a vCenter that is
|
||||
// down, or that this run was not asked about (`-v v308`), must not lose its
|
||||
// machines out of the cache — the point of completion is to work when things
|
||||
// are not working. Best effort throughout: a cache that cannot be written is
|
||||
// not worth a word on the screen, let alone an error.
|
||||
func saveInventory(answered []string, rows []vmRow) {
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fresh := map[string]bool{}
|
||||
for _, name := range answered {
|
||||
fresh[name] = true
|
||||
}
|
||||
|
||||
kept := make([]cacheEntry, 0, len(rows))
|
||||
for _, e := range loadInventory() {
|
||||
if !fresh[e.vc] {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, r := range rows {
|
||||
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, e := range kept {
|
||||
// One line per machine: the server, when it was read, and the name.
|
||||
// Tab separated because a machine name may hold a space and never a tab.
|
||||
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
|
||||
return
|
||||
}
|
||||
if os.Rename(tmp, path) != nil {
|
||||
os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// loadInventory reads it back. A line that does not parse is dropped rather
|
||||
// than reported: this file is a convenience and a broken one means one Tab
|
||||
// without an answer.
|
||||
func loadInventory() []cacheEntry {
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []cacheEntry
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) != 3 || f[0] == "" || f[2] == "" {
|
||||
continue
|
||||
}
|
||||
when, err := time.Parse(time.RFC3339, f[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cachedNames are the machine names that begin with the prefix, once each and
|
||||
// in order. Once each because the same name on two vCenters is one thing to
|
||||
// type; in order because a completion list that moves about is a completion
|
||||
// list nobody reads.
|
||||
func cachedNames(prefix string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range loadInventory() {
|
||||
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
|
||||
continue
|
||||
}
|
||||
seen[e.name] = true
|
||||
out = append(out, e.name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// cachedVCenters are the servers the cache has seen, which is what `-v`
|
||||
// completes against. It comes out of the cache and not out of ~/.gvmrc on
|
||||
// purpose: reading the configuration would seal a password standing in the
|
||||
// clear in it, and a Tab key must not rewrite a file.
|
||||
func cachedVCenters() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range loadInventory() {
|
||||
if seen[e.vc] {
|
||||
continue
|
||||
}
|
||||
seen[e.vc] = true
|
||||
out = append(out, e.vc)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// inventoryAge is what `gvm config` says about the cache: how many machines it
|
||||
// holds and how long ago each server was read.
|
||||
func inventoryAge() string {
|
||||
entries := loadInventory()
|
||||
if len(entries) == 0 {
|
||||
return "-"
|
||||
}
|
||||
newest := map[string]time.Time{}
|
||||
var order []string
|
||||
for _, e := range entries {
|
||||
if _, seen := newest[e.vc]; !seen {
|
||||
order = append(order, e.vc)
|
||||
}
|
||||
if e.when.After(newest[e.vc]) {
|
||||
newest[e.vc] = e.when
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, vc := range order {
|
||||
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
|
||||
}
|
||||
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- the scripts
|
||||
|
||||
// vmFlags are the options that take a machine name and vcFlags the ones that
|
||||
// take a vCenter — the only thing about gvm's own command line that the
|
||||
// completion has to be told, because it is the only thing flaggy's generated
|
||||
// script cannot know: it knows every option there is, and nothing about what
|
||||
// any of them means.
|
||||
//
|
||||
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
|
||||
// an option renamed there cannot leave a completion quietly offering the wrong
|
||||
// thing.
|
||||
var (
|
||||
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
|
||||
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
|
||||
"--off", "--reset"}
|
||||
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
|
||||
)
|
||||
|
||||
// installedFunction is the completion function flaggy's own script installs,
|
||||
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
|
||||
// "complete -F _gvm_complete gvm" in bash.
|
||||
//
|
||||
// Taken from the script rather than written down here, because the name is
|
||||
// flaggy's to choose: it builds it out of the parser's name, and a version that
|
||||
// built it differently would leave the addendum below calling a function that
|
||||
// does not exist, which in a shell is a completion that silently offers
|
||||
// nothing.
|
||||
func installedFunction(script string) string {
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
f := strings.Fields(line)
|
||||
switch {
|
||||
case len(f) >= 2 && f[0] == "compdef":
|
||||
return f[1]
|
||||
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
|
||||
return f[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
|
||||
// offers and lists in the help, answered here instead so that the script can
|
||||
// carry the machine names as well. A shell this does not know is left to
|
||||
// flaggy, whose own message names the ones it can write.
|
||||
func completionRequest(args []string) (shell string, ok bool) {
|
||||
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(args[1]), true
|
||||
}
|
||||
|
||||
// completionScript is flaggy's script for that shell with the names put on top:
|
||||
//
|
||||
// eval "$(gvm completion zsh)"
|
||||
// gvm completion bash > /etc/bash_completion.d/gvm
|
||||
//
|
||||
// flaggy generates the half that is about gvm's own command line, from the
|
||||
// parser itself, so no list here can fall behind the options that exist. This
|
||||
// adds the half that is about the estate: after an option that takes a machine,
|
||||
// the machines; after -v, the servers. Both go through --complete-vms, which
|
||||
// reads the cache and never a vCenter.
|
||||
func completionScript(shell, flaggyScript string) (string, bool) {
|
||||
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
// Without a function of flaggy's to fall back to there is nothing to add
|
||||
// to: half a completion — machine names and no options — would be worse
|
||||
// than the whole of flaggy's, which is what this then leaves in place.
|
||||
delegate := installedFunction(flaggyScript)
|
||||
if delegate == "" {
|
||||
return flaggyScript, true
|
||||
}
|
||||
return flaggyScript + names(delegate), true
|
||||
}
|
||||
|
||||
func zshNames(delegate string) string {
|
||||
return strings.Join([]string{
|
||||
"",
|
||||
"# gvm: the machines and the servers, from what gvm last saw",
|
||||
"_gvm_names() {",
|
||||
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
|
||||
" case $prev in",
|
||||
" " + strings.Join(vmFlags, "|") + ")",
|
||||
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
|
||||
" " + strings.Join(vcFlags, "|") + ")",
|
||||
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
|
||||
" esac",
|
||||
" " + delegate + " \"$@\"",
|
||||
"}",
|
||||
"compdef _gvm_names gvm",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func bashNames(delegate string) string {
|
||||
return strings.Join([]string{
|
||||
"",
|
||||
"# gvm: the machines and the servers, from what gvm last saw",
|
||||
"_gvm_names() {",
|
||||
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
|
||||
" case $prev in",
|
||||
" " + strings.Join(vmFlags, "|") + ")",
|
||||
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
|
||||
" " + strings.Join(vcFlags, "|") + ")",
|
||||
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
|
||||
" esac",
|
||||
" " + delegate,
|
||||
"}",
|
||||
"complete -F _gvm_names gvm",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
|
||||
// it stands — every subcommand and every option, without a list here to fall
|
||||
// behind them. Empty for a shell flaggy does not write, which is the caller's
|
||||
// signal to let flaggy answer for itself.
|
||||
func flaggyCompletion(shell string) string {
|
||||
switch shell {
|
||||
case "zsh":
|
||||
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
|
||||
case "bash":
|
||||
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
// cacheHome points the cache directory at a temporary one, so a test never
|
||||
// reads or writes the cache of the person running it. os.UserCacheDir goes by
|
||||
// HOME on macOS and by XDG_CACHE_HOME on Linux, so both are set.
|
||||
func cacheHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func cacheRows(vc string, names ...string) []vmRow {
|
||||
var rows []vmRow
|
||||
for _, n := range names {
|
||||
rows = append(rows, vmRow{vc: VCenter{Name: vc}, name: n})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestInventoryCacheRoundTrip(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"db01", "web01"}) {
|
||||
t.Errorf("the cache gave back %v", got)
|
||||
}
|
||||
if got := cachedVCenters(); !slices.Equal(got, []string{"v308"}) {
|
||||
t.Errorf("the servers came back as %v", got)
|
||||
}
|
||||
if got := cachedNames("web"); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the prefix web matched %v", got)
|
||||
}
|
||||
// A shell completes what has been typed so far, whichever case it is in.
|
||||
if got := cachedNames("WEB"); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the prefix WEB matched %v", got)
|
||||
}
|
||||
if got := cachedNames("nothing-like-this"); len(got) != 0 {
|
||||
t.Errorf("a prefix that matches nothing gave %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A sweep of one server must not lose the others' machines. The point of
|
||||
// completing out of a cache is that it works when a vCenter is down — or when
|
||||
// the last command was `gvm -v v308 vm -l`.
|
||||
func TestASweepOfOneServerKeepsTheOthers(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308", "v108"},
|
||||
append(cacheRows("v308", "web01"), cacheRows("v108", "old01")...))
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "web02"))
|
||||
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"old01", "web01", "web02"}) {
|
||||
t.Errorf("after a sweep of one server the cache holds %v", got)
|
||||
}
|
||||
if got := cachedVCenters(); !slices.Equal(got, []string{"v108", "v308"}) {
|
||||
t.Errorf("the servers came back as %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that has gone is gone from the cache of the server it was on.
|
||||
func TestTheCacheForgetsWhatTheServerHasForgotten(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "temp01"))
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the cache still holds %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A cache that cannot be read is one Tab without an answer, never an error.
|
||||
func TestABrokenCacheIsSilent(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("nonsense\nv308\tnot-a-date\tweb01\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cachedNames(""); len(got) != 0 {
|
||||
t.Errorf("a broken cache offered %v", got)
|
||||
}
|
||||
if got := inventoryAge(); got != "-" {
|
||||
t.Errorf("a broken cache is described as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The file holds machine names read off a vCenter, and the cache directory is
|
||||
// not private, so it is written the way the configuration is.
|
||||
func TestTheCacheIsNotWorldReadable(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
path, _ := inventoryPath()
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the cache is mode %v", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// `gvm config` says how old the cache is, because a completion offering a
|
||||
// machine deleted last month should be explicable.
|
||||
func TestConfigSaysHowOldTheCacheIs(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
|
||||
|
||||
got := inventoryAge()
|
||||
if !strings.Contains(got, "2 machines") || !strings.Contains(got, "v308") {
|
||||
t.Errorf("inventoryAge = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "ago") {
|
||||
t.Errorf("inventoryAge does not say when: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The scripts complete against the cache and never against a vCenter: a Tab key
|
||||
// that logs in three times is a Tab key nobody presses twice.
|
||||
func TestTheCompletionScriptsAskGvmAndNothingElse(t *testing.T) {
|
||||
for _, shell := range []string{"zsh", "bash"} {
|
||||
script, ok := completionScript(shell, "# flaggy's half\n_half() {\n}\ncompdef _half gvm\n")
|
||||
if !ok {
|
||||
t.Fatalf("no script for %s", shell)
|
||||
}
|
||||
if !strings.Contains(script, "--complete-vms") || !strings.Contains(script, "--complete-vcenters") {
|
||||
t.Errorf("the %s script does not ask gvm for the names:\n%s", shell, script)
|
||||
}
|
||||
// flaggy's half is carried, not replaced: that is the half that knows
|
||||
// every subcommand and every option.
|
||||
if !strings.Contains(script, "# flaggy's half") {
|
||||
t.Errorf("the %s script threw flaggy's own half away", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_gvm_names") {
|
||||
t.Errorf("the %s script does not install itself", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_half") {
|
||||
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_half") {
|
||||
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
|
||||
}
|
||||
}
|
||||
|
||||
// A shell neither half knows is left to flaggy, whose own message names the
|
||||
// ones it can write.
|
||||
if _, ok := completionScript("klingon", ""); ok {
|
||||
t.Error("a script was written for a shell nobody has")
|
||||
}
|
||||
}
|
||||
|
||||
// The addendum falls back to the function flaggy's own script installs, whose
|
||||
// name is read off the script rather than written down twice. If flaggy ever
|
||||
// names it differently the wrapper follows it there.
|
||||
func TestTheAddendumDelegatesToFlaggysOwnFunction(t *testing.T) {
|
||||
for _, c := range []struct{ script, want string }{
|
||||
{"_gvm() {\n}\ncompdef _gvm gvm\n", "_gvm"},
|
||||
{"_gvm_complete() {\n}\ncomplete -F _gvm_complete gvm\n", "_gvm_complete"},
|
||||
{"_thing() {\n}\ncompdef _some_other_name thing\n", "_some_other_name"},
|
||||
{"nothing installs anything here\n", ""},
|
||||
} {
|
||||
if got := installedFunction(c.script); got != c.want {
|
||||
t.Errorf("installedFunction found %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And on the real thing: flaggy builds the name out of the parser's name,
|
||||
// which is the only part of the parser this depends on.
|
||||
flaggy.SetName("gvm")
|
||||
for _, shell := range []string{"zsh", "bash"} {
|
||||
generated := flaggyCompletion(shell)
|
||||
delegate := installedFunction(generated)
|
||||
if delegate == "" {
|
||||
t.Fatalf("nothing could be found to delegate to in flaggy's %s script:\n%s", shell, generated)
|
||||
}
|
||||
if !strings.Contains(generated, delegate+"()") {
|
||||
t.Errorf("flaggy's %s script installs %s without defining it", shell, delegate)
|
||||
}
|
||||
script, _ := completionScript(shell, generated)
|
||||
if !strings.Contains(script, delegate) {
|
||||
t.Errorf("the %s addendum does not fall back to %s", shell, delegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A shell script that is not valid shell is worse than none: the shell says so
|
||||
// on every Tab. Both are checked with the shell's own parser, where there is one.
|
||||
func TestTheCompletionScriptsAreValidShell(t *testing.T) {
|
||||
flaggy.SetName("gvm")
|
||||
for _, c := range []struct{ shell, flag string }{{"zsh", "-n"}, {"bash", "-n"}} {
|
||||
if _, err := exec.LookPath(c.shell); err != nil {
|
||||
t.Logf("no %s here to check with", c.shell)
|
||||
continue
|
||||
}
|
||||
script, ok := completionScript(c.shell, flaggyCompletion(c.shell))
|
||||
if !ok {
|
||||
t.Fatalf("no %s script", c.shell)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "completion."+c.shell)
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(c.shell, c.flag, path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("the %s script does not parse: %v\n%s\n%s", c.shell, err, out, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `gvm completion <shell>` is flaggy's own subcommand, answered a step earlier.
|
||||
// It has to be recognised exactly as flaggy would recognise it, or the two
|
||||
// disagree about what the command line said.
|
||||
func TestCompletionRequest(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
shell string
|
||||
ok bool
|
||||
}{
|
||||
{[]string{"completion", "zsh"}, "zsh", true},
|
||||
{[]string{"completion", "BASH"}, "bash", true},
|
||||
{[]string{"Completion", "zsh"}, "zsh", true},
|
||||
{[]string{"completion"}, "", false}, // flaggy asks which shell
|
||||
{[]string{"vm", "-l"}, "", false},
|
||||
{nil, "", false},
|
||||
} {
|
||||
shell, ok := completionRequest(c.args)
|
||||
if ok != c.ok || shell != c.shell {
|
||||
t.Errorf("completionRequest(%v) = %q, %v; want %q, %v", c.args, shell, ok, c.shell, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every option the names are offered after is an option gvm actually has. The
|
||||
// two lists cannot be one — a shell script has to name them as strings — so
|
||||
// this is what keeps them from drifting apart. The subcommands and the rest of
|
||||
// the options need no such check: flaggy writes those out of the parser itself.
|
||||
func TestCompletionOptionsAreReal(t *testing.T) {
|
||||
src, err := os.ReadFile("gvm.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(src)
|
||||
|
||||
for _, f := range append(append([]string{}, vmFlags...), vcFlags...) {
|
||||
if !strings.Contains(text, `"`+strings.TrimLeft(f, "-")+`"`) {
|
||||
t.Errorf("the completion offers %s, which gvm.go does not declare", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix beginning with a dash is still a prefix: the scripts put "--" in
|
||||
// front of it so it cannot be taken for an option of gvm's own.
|
||||
func TestACompletionPrefixMayLookLikeAnOption(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "-odd-name", "web01"))
|
||||
|
||||
if got := cachedNames("-odd"); !slices.Equal(got, []string{"-odd-name"}) {
|
||||
t.Errorf("the prefix -odd matched %v", got)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type Config struct {
|
||||
SMTPHost string // relay to hand it to
|
||||
SMTPPort string // its port (default 25)
|
||||
Telemetry string // URL `host -t` posts to; unset turns the posting off
|
||||
SSH string // the command the 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
@@ -0,0 +1,192 @@
|
||||
// datastore.go — what the datastores are doing.
|
||||
//
|
||||
// The gap next to `gvm host`. A cluster is watched by its processor load and its
|
||||
// memory, and then it falls over because a datastore filled up — which nothing
|
||||
// in gvm could show, and which is the one figure a snapshot report (snapold.go)
|
||||
// makes you want to look at next.
|
||||
//
|
||||
// Same shape as hoststat: one line per datastore, the numbers in the palette's
|
||||
// roles, and -t posts the same figures to the monitoring server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/units"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
var dsColumns = []printColumn{
|
||||
{header: "DATASTORE", width: 20},
|
||||
{header: "TYPE", width: 5},
|
||||
{header: "CAPACITY", width: 9, right: true},
|
||||
{header: "FREE", width: 9, right: true},
|
||||
{header: "USED%", width: 6, right: true},
|
||||
{header: "PROVISIONED", width: 12, right: true},
|
||||
{header: "OVER%", width: 6, right: true},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "STATUS", width: 7},
|
||||
{header: "STATE", width: 14},
|
||||
}
|
||||
|
||||
// dsstat prints one line per datastore: how big it is, what is left, what has
|
||||
// been promised out of it, and how many machines live on it.
|
||||
//
|
||||
// Every figure comes out of summary, and vSphere only guarantees those while
|
||||
// the datastore is accessible. An unreachable datastore therefore reports
|
||||
// dashes rather than zeroes — a datastore that says 0 B free looks like an
|
||||
// emergency, and a datastore nobody can reach is a different one.
|
||||
func dsstat(vc VCenter, telemetry string) error {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.close()
|
||||
|
||||
stores, err := s.datastores("name", "summary", "overallStatus", "vm")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(stores, func(a, b int) bool {
|
||||
return strings.ToLower(dsName(stores[a])) < strings.ToLower(dsName(stores[b]))
|
||||
})
|
||||
|
||||
var capacity, free int64
|
||||
printRow(dsColumns, "", nil)
|
||||
for _, ds := range stores {
|
||||
sum := ds.Summary
|
||||
used, usedKnown := dsUsedPercent(sum)
|
||||
over, overKnown := dsOverPercent(sum)
|
||||
if sum.Accessible {
|
||||
capacity += sum.Capacity
|
||||
free += sum.FreeSpace
|
||||
}
|
||||
|
||||
printRow(dsColumns, "", []cell{
|
||||
{dsName(ds), cWhite.fg()},
|
||||
{sum.Type, colAside},
|
||||
dsSize(sum.Capacity, sum.Accessible),
|
||||
dsSize(sum.FreeSpace, sum.Accessible),
|
||||
pctCell(used, usedKnown, loadColor(used, usedKnown)),
|
||||
dsSize(dsProvisioned(sum), sum.Accessible),
|
||||
pctCell(over, overKnown, overColor(over, overKnown)),
|
||||
{Itoa(len(ds.Vm)), colSize},
|
||||
{string(ds.OverallStatus), statusColor(ds.OverallStatus)},
|
||||
{dsState(sum), dsStateColor(sum)},
|
||||
})
|
||||
|
||||
if telemetry != "" {
|
||||
post(telemetry, SF("ds,%s,%d,%d,%.2f,%d,%d,%s",
|
||||
dsName(ds), sum.Capacity, sum.FreeSpace, used,
|
||||
dsProvisioned(sum), len(ds.Vm), ds.OverallStatus))
|
||||
}
|
||||
}
|
||||
|
||||
// One line of estate: the figure somebody asks for immediately after
|
||||
// reading the table, and the reason the table is worth printing at all.
|
||||
if capacity > 0 {
|
||||
P()
|
||||
PF("%s %s of %s free (%s used)\n",
|
||||
Cwb(plural(len(stores), "datastore")),
|
||||
Co(units.ByteSize(free).String()),
|
||||
Co(units.ByteSize(capacity).String()),
|
||||
Co(SF("%.0f %%", 100.0-100.0/float64(capacity)*float64(free))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsName prefers the summary's name over the entity's: they are two different
|
||||
// vSphere properties and a datastore that was renamed can answer differently to
|
||||
// each, the same way a host can (see countOn in host.go).
|
||||
func dsName(ds mo.Datastore) string {
|
||||
if ds.Summary.Name != "" {
|
||||
return ds.Summary.Name
|
||||
}
|
||||
return ds.Name
|
||||
}
|
||||
|
||||
// dsProvisioned is what has been promised out of the datastore: what is in use
|
||||
// plus what thin disks are entitled to grow into. Past the capacity that is a
|
||||
// promise the datastore cannot keep if every machine takes what it was offered,
|
||||
// which is why it has a column of its own rather than being folded into "used".
|
||||
func dsProvisioned(sum types.DatastoreSummary) int64 {
|
||||
return sum.Capacity - sum.FreeSpace + sum.Uncommitted
|
||||
}
|
||||
|
||||
func dsUsedPercent(sum types.DatastoreSummary) (float64, bool) {
|
||||
if !sum.Accessible || sum.Capacity <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 - 100.0/float64(sum.Capacity)*float64(sum.FreeSpace), true
|
||||
}
|
||||
|
||||
func dsOverPercent(sum types.DatastoreSummary) (float64, bool) {
|
||||
if !sum.Accessible || sum.Capacity <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 / float64(sum.Capacity) * float64(dsProvisioned(sum)), true
|
||||
}
|
||||
|
||||
// overColor: thin provisioning past the capacity is ordinary and not a fault,
|
||||
// so a hundred per cent is a word of warning rather than an alarm; half again
|
||||
// as much as there is, is an alarm.
|
||||
func overColor(pct float64, known bool) string {
|
||||
switch {
|
||||
case !known:
|
||||
return colOff
|
||||
case pct >= 150:
|
||||
return colFull
|
||||
case pct >= 100:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// dsSize is a byte figure, or a dash where the datastore cannot vouch for it.
|
||||
func dsSize(b int64, accessible bool) cell {
|
||||
if !accessible {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{units.ByteSize(b).String(), colSize}
|
||||
}
|
||||
|
||||
// pctCell is a percentage in a narrow column: no sign, because the header has
|
||||
// one, and a dash where there is no figure rather than a nought.
|
||||
func pctCell(pct float64, known bool, col string) cell {
|
||||
if !known {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{SF("%.0f", pct), col}
|
||||
}
|
||||
|
||||
// dsState is what vSphere says about the datastore itself, as opposed to the
|
||||
// alarms rolled up in its status: whether it can be reached at all, and whether
|
||||
// it is being emptied for removal.
|
||||
func dsState(sum types.DatastoreSummary) string {
|
||||
var parts []string
|
||||
if !sum.Accessible {
|
||||
parts = append(parts, "inaccessible")
|
||||
}
|
||||
switch sum.MaintenanceMode {
|
||||
case "", string(types.DatastoreSummaryMaintenanceModeStateNormal):
|
||||
default:
|
||||
parts = append(parts, string(sum.MaintenanceMode))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "ok"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func dsStateColor(sum types.DatastoreSummary) string {
|
||||
if !sum.Accessible {
|
||||
return colFull
|
||||
}
|
||||
if sum.MaintenanceMode != "" &&
|
||||
sum.MaintenanceMode != string(types.DatastoreSummaryMaintenanceModeStateNormal) {
|
||||
return colBusy
|
||||
}
|
||||
return colOK
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
func dsSummary(capacity, free, uncommitted int64, accessible bool) types.DatastoreSummary {
|
||||
return types.DatastoreSummary{
|
||||
Name: "LocalDS_0", Type: "VMFS", Capacity: capacity,
|
||||
FreeSpace: free, Uncommitted: uncommitted, Accessible: accessible,
|
||||
}
|
||||
}
|
||||
|
||||
// What has been promised out of a datastore is what is in use plus what thin
|
||||
// disks may still grow into — the figure that says whether the datastore can
|
||||
// keep its promises, and the reason it is a column of its own.
|
||||
func TestProvisionedAndUsed(t *testing.T) {
|
||||
sum := dsSummary(1000, 400, 800, true)
|
||||
|
||||
if got := dsProvisioned(sum); got != 1400 {
|
||||
t.Errorf("provisioned = %d, want 1400", got)
|
||||
}
|
||||
used, ok := dsUsedPercent(sum)
|
||||
if !ok || used != 60 {
|
||||
t.Errorf("used = %v (known %v), want 60", used, ok)
|
||||
}
|
||||
over, ok := dsOverPercent(sum)
|
||||
if !ok || over != 140 {
|
||||
t.Errorf("over = %v (known %v), want 140", over, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A datastore nobody can reach cannot vouch for its own figures, so it reports
|
||||
// none. A datastore that says 0 B free looks like an emergency; one that cannot
|
||||
// be reached is a different one.
|
||||
func TestAnUnreachableDatastoreReportsNothing(t *testing.T) {
|
||||
sum := dsSummary(1000, 0, 0, false)
|
||||
|
||||
if _, ok := dsUsedPercent(sum); ok {
|
||||
t.Error("an inaccessible datastore reported a usage figure")
|
||||
}
|
||||
if _, ok := dsOverPercent(sum); ok {
|
||||
t.Error("an inaccessible datastore reported an over-commitment figure")
|
||||
}
|
||||
if got := dsSize(1000, false); got.text != "-" || got.col != colOff {
|
||||
t.Errorf("its capacity is shown as %q", got.text)
|
||||
}
|
||||
if dsState(sum) != "inaccessible" || dsStateColor(sum) != colFull {
|
||||
t.Errorf("its state is %q", dsState(sum))
|
||||
}
|
||||
}
|
||||
|
||||
// Thin provisioning past the capacity is ordinary practice, not a fault: a word
|
||||
// of warning at a hundred per cent, an alarm at half again as much.
|
||||
func TestOverCommitmentColours(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want string
|
||||
}{{50, colSize}, {99, colSize}, {100, colBusy}, {149, colBusy}, {150, colFull}} {
|
||||
if got := overColor(c.pct, true); got != c.want {
|
||||
t.Errorf("%.0f %% over-committed is coloured wrongly", c.pct)
|
||||
}
|
||||
}
|
||||
if overColor(0, false) != colOff {
|
||||
t.Error("an unknown over-commitment is coloured as a figure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentCellHasNoSignAndNoNought(t *testing.T) {
|
||||
if got := pctCell(93.4, true, colSize); got.text != "93" {
|
||||
t.Errorf("a percentage is shown as %q", got.text)
|
||||
}
|
||||
if got := pctCell(0, false, colSize); got.text != "-" {
|
||||
t.Errorf("an unknown percentage is shown as %q", got.text)
|
||||
}
|
||||
}
|
||||
|
||||
// A datastore in maintenance is being emptied on purpose: worth saying, not
|
||||
// worth an alarm.
|
||||
func TestMaintenanceModeIsSaidButNotAlarmed(t *testing.T) {
|
||||
sum := dsSummary(1000, 500, 0, true)
|
||||
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateEnteringMaintenance)
|
||||
|
||||
if got := dsState(sum); got != "enteringMaintenance" {
|
||||
t.Errorf("the state reads %q", got)
|
||||
}
|
||||
if dsStateColor(sum) != colBusy {
|
||||
t.Error("entering maintenance is painted as a fault")
|
||||
}
|
||||
|
||||
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateNormal)
|
||||
if got := dsState(sum); got != "ok" {
|
||||
t.Errorf("an ordinary datastore reads %q", got)
|
||||
}
|
||||
if dsStateColor(sum) != colOK {
|
||||
t.Error("an ordinary datastore is not painted as ordinary")
|
||||
}
|
||||
}
|
||||
|
||||
// The name in the summary and the name of the entity are two different vSphere
|
||||
// properties, and a renamed datastore can answer differently to each — the same
|
||||
// trap the host counts fell into.
|
||||
func TestDatastoreNamePrefersTheSummary(t *testing.T) {
|
||||
ds := mo.Datastore{Summary: types.DatastoreSummary{Name: "new-name"}}
|
||||
ds.Name = "old-name"
|
||||
if got := dsName(ds); got != "new-name" {
|
||||
t.Errorf("dsName = %q", got)
|
||||
}
|
||||
|
||||
ds.Summary.Name = ""
|
||||
if got := dsName(ds); got != "old-name" {
|
||||
t.Errorf("with no summary name, dsName = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// events.go — one machine's recent history.
|
||||
//
|
||||
// `gvm log` is the whole vCenter over the last hour, which is the right shape
|
||||
// for a mail and the wrong one for the question actually being asked in front
|
||||
// of a machine's sheet: why is this thing off, who rebooted it, what happened
|
||||
// at four this morning. vCenter keeps the answer per object, so this asks it
|
||||
// per object.
|
||||
//
|
||||
// It is not part of the sheet's own reading. Opening a machine costs one call
|
||||
// for its snapshots and nothing else, and it stays that way: the events are
|
||||
// fetched when they are asked for (the action menu's 'e'), for the one machine
|
||||
// on screen.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/event"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// How many events are worth having, and how long to wait for them. The page is
|
||||
// short on purpose: this answers "what just happened to this machine", and
|
||||
// anything older than the last couple of dozen lines is a question for `log`.
|
||||
const (
|
||||
eventPage = 25
|
||||
eventWait = 20 * time.Second
|
||||
)
|
||||
|
||||
// eventLine is one event as the sheet shows it.
|
||||
type eventLine struct {
|
||||
text string
|
||||
col string
|
||||
}
|
||||
|
||||
// eventsOf reads the machine's most recent events, oldest first — the order a
|
||||
// history reads in, so the last line is the latest thing that happened.
|
||||
func eventsOf(r vmRow) ([]eventLine, error) {
|
||||
if r.sess == nil {
|
||||
return nil, errf("no connection to %s", r.vc.Name)
|
||||
}
|
||||
|
||||
// Bounded. The underlying collector waits for vCenter to hand over its
|
||||
// first page, and this runs in the interactive loop: a server that accepts
|
||||
// the request and then says nothing would otherwise freeze the screen
|
||||
// mid-draw with no key being read.
|
||||
ctx, cancel := context.WithTimeout(r.sess.ctx, eventWait)
|
||||
defer cancel()
|
||||
|
||||
var found []types.BaseEvent
|
||||
err := event.NewManager(r.sess.client.Client).Events(ctx,
|
||||
[]types.ManagedObjectReference{r.ref}, eventPage, false, false,
|
||||
func(_ types.ManagedObjectReference, evs []types.BaseEvent) error {
|
||||
found = append(found, evs...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, errf("%s did not answer within %s", r.vc.Name, eventWait)
|
||||
}
|
||||
return nil, errf("%s: cannot read the events of %s: %w", r.vc.Name, r.name, err)
|
||||
}
|
||||
|
||||
out := make([]eventLine, 0, len(found))
|
||||
for _, e := range found {
|
||||
out = append(out, eventLineOf(e))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eventLineOf is one event: when, and what. The severity decides the colour and
|
||||
// is otherwise left out — "info" down twenty lines is twenty times four
|
||||
// characters spent saying nothing.
|
||||
func eventLineOf(e types.BaseEvent) eventLine {
|
||||
base := e.GetEvent()
|
||||
msg := strings.TrimSpace(base.FullFormattedMessage)
|
||||
if msg == "" {
|
||||
msg = SF("%T", e)
|
||||
}
|
||||
// One line per event: a formatted vSphere message can carry newlines, and a
|
||||
// value with a newline in it would break the sheet's own line counting.
|
||||
msg = strings.Join(strings.Fields(msg), " ")
|
||||
|
||||
return eventLine{
|
||||
text: base.CreatedTime.Local().Format("02.01. 15:04") + " " + msg,
|
||||
col: eventColor(severity(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// eventColor takes log.go's reading of the severity into the palette. Only the
|
||||
// two that matter are coloured; the rest is history, and history is grey.
|
||||
func eventColor(sev string) string {
|
||||
switch strings.ToLower(sev) {
|
||||
case "error":
|
||||
return colFull
|
||||
case "warning":
|
||||
return colBusy
|
||||
}
|
||||
return colAside
|
||||
}
|
||||
@@ -4,10 +4,8 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/eknkc/basex v1.0.1
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/vmware/govmomi v0.53.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
@@ -18,8 +16,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
|
||||
@@ -7,8 +7,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
|
||||
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -35,12 +33,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/vmware/govmomi v0.53.0 h1:e1bZCotAq7wm4xy95ePN2uoWwz28pNp/ewZZhpBY7/4=
|
||||
github.com/vmware/govmomi v0.53.0/go.mod h1:EWfuzPfxT5NV+aS2we02SLFdhvJkgeY7t7+TszgBSMY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// guest.go — the three ways out of a machine's sheet.
|
||||
//
|
||||
// Everything else in gvm looks at machines. These look them up: log in to one,
|
||||
// take its address away with you, open it in the vSphere client. They are the
|
||||
// keystrokes that stop gvm being a viewer you then have to type an address out
|
||||
// of by hand — and none of them touches the vCenter at all, which is why they
|
||||
// are letters of the sheet itself rather than entries in the action menu.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// sshTarget is what to connect to: the name the guest calls itself, or its
|
||||
// address. The name is preferred where there is one — it is what is in the known
|
||||
// hosts file, and an address that came out of VMware Tools may be one of several.
|
||||
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
|
||||
|
||||
// sshTargetIs is the same, plus which of the two it turned out to be. What was
|
||||
// copied is worth naming — a sheet shows a hostname and an address, and
|
||||
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
|
||||
// working that out a second time somewhere else is how two answers drift apart.
|
||||
func (r vmRow) sshTargetIs() (target, kind string) {
|
||||
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
|
||||
return strings.TrimSpace(g.HostName), "hostname"
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
return ip, "address"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// defaultSSH is what `h` runs when the configuration says nothing.
|
||||
const defaultSSH = "ssh root@%h"
|
||||
|
||||
// sshCommand is the command line to run, as argv: the configured template with
|
||||
// the target put where %h stands, or appended when it does not stand anywhere.
|
||||
//
|
||||
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
|
||||
// anything else is a second step once the session is up — and having it in the
|
||||
// default means the common case needs no configuration file at all. A template
|
||||
// of one's own overrides it entirely, root and all.
|
||||
//
|
||||
// The target is its own argument and never goes through a shell. It comes from
|
||||
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
|
||||
// and a guest that called itself `; rm -rf ~` would otherwise be running that
|
||||
// on the operator's workstation. The template is the operator's own line out of
|
||||
// their own configuration file, so it is split on spaces and no further:
|
||||
// quoting is not supported, which is a limit worth having here.
|
||||
func sshCommand(template, target string) []string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
template = defaultSSH
|
||||
}
|
||||
fields := strings.Fields(template)
|
||||
|
||||
argv := make([]string, 0, len(fields)+1)
|
||||
placed := false
|
||||
for _, f := range fields {
|
||||
if strings.Contains(f, "%h") {
|
||||
argv = append(argv, strings.ReplaceAll(f, "%h", target))
|
||||
placed = true
|
||||
continue
|
||||
}
|
||||
argv = append(argv, f)
|
||||
}
|
||||
if !placed {
|
||||
argv = append(argv, target)
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
// vsphereURL is the machine's page in the vSphere client.
|
||||
//
|
||||
// The shape is the H5 client's own: the object's reference and the vCenter's
|
||||
// instance UUID, which is the serverGuid that client puts in every link. The
|
||||
// UUID cannot be worked out from the configuration — it is asked of the server
|
||||
// on connecting (session.instanceUUID) — so a machine read over a connection
|
||||
// that has gone has no URL rather than a wrong one.
|
||||
func vsphereURL(r vmRow) string {
|
||||
if r.sess == nil {
|
||||
return ""
|
||||
}
|
||||
return vsphereLink(r.vc.URL, r.ref.Value, r.sess.instanceUUID())
|
||||
}
|
||||
|
||||
// vsphereLink is the link itself, from the three things it is made of — so the
|
||||
// shape can be checked without a server, which is the only way it can be
|
||||
// checked at all: a wrong link opens a client that says "object not found",
|
||||
// which looks like a vCenter problem rather than a gvm one.
|
||||
func vsphereLink(vcURL, moref, guid string) string {
|
||||
if vcURL == "" || moref == "" || guid == "" {
|
||||
return ""
|
||||
}
|
||||
return SF("%s/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:%s:%s/summary",
|
||||
strings.TrimSuffix(vcURL, "/"), moref, guid)
|
||||
}
|
||||
|
||||
// openerCommand is how this operating system opens a URL. Nothing is opened
|
||||
// where there is no answer rather than something being guessed at, and the
|
||||
// caller says the URL out loud instead — which is the useful half anyway.
|
||||
func openerCommand() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "open"
|
||||
case "linux":
|
||||
return "xdg-open"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// openURL hands the URL to the desktop and does not wait for it. A browser
|
||||
// takes seconds to start and prints its own complaints; neither belongs in a
|
||||
// full-screen list.
|
||||
func openURL(url string) error {
|
||||
opener := openerCommand()
|
||||
if opener == "" {
|
||||
return errf("no way to open a browser on %s", runtime.GOOS)
|
||||
}
|
||||
cmd := exec.Command(opener, url)
|
||||
cmd.Stdout, cmd.Stderr = nil, nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return errf("cannot run %s: %w", opener, err)
|
||||
}
|
||||
go cmd.Wait() // reaped in the background; nothing here waits on a browser
|
||||
return nil
|
||||
}
|
||||
|
||||
// osc52 is the escape sequence that puts text in the clipboard of the terminal
|
||||
// that is being looked at, wherever that terminal is running. It is the only way
|
||||
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
|
||||
// end of a login copies into the clipboard of a machine nobody is sitting at.
|
||||
//
|
||||
// It is also the way a terminal is free to ignore, and several do until they are
|
||||
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
|
||||
// is why it is not the only thing tried. See toClipboard.
|
||||
func osc52(text string) string {
|
||||
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
|
||||
}
|
||||
|
||||
// toClipboard puts text where the next paste will find it and reports the way it
|
||||
// got there, named — "pbcopy" — or empty when the escape sequence was the only
|
||||
// thing on offer. The caller says so on the status line: a copy nobody can see
|
||||
// happen is one that has to be described, or the only way to find out whether it
|
||||
// worked is to paste somewhere and look.
|
||||
//
|
||||
// Both routes are used, because either alone leaves somebody with nothing: the
|
||||
// local command always works where there is one, and the sequence is what
|
||||
// carries the text home from the far end of an ssh login.
|
||||
func (b *browser) toClipboard(text string) string {
|
||||
b.write(osc52(text))
|
||||
|
||||
argv := clipTool()
|
||||
if argv == nil {
|
||||
return ""
|
||||
}
|
||||
if err := runClipTool(argv, text); err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(argv[0])
|
||||
}
|
||||
|
||||
// clipTool is the command that puts something in this machine's clipboard, where
|
||||
// this is the machine the person is sitting at. Over an ssh login it is not:
|
||||
// there the terminal's own sequence is the only route that ends up where the
|
||||
// person can paste it, and a local clipboard would be the wrong machine's.
|
||||
func clipTool() []string {
|
||||
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = [][]string{{"pbcopy"}}
|
||||
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
// A Linux console or a machine with no session to speak of: there is
|
||||
// nothing for xclip to hand the text to, and it would sit there waiting.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if path, err := exec.LookPath(c[0]); err == nil {
|
||||
return append([]string{path}, c[1:]...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runClipTool feeds the text to it on standard input, which is how all of them
|
||||
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
|
||||
// hostname into a pasted hostname and a return.
|
||||
func runClipTool(argv []string, text string) error {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// runInTerminal gives the terminal back, runs a command in it, and takes it
|
||||
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
|
||||
// and the keyboard.
|
||||
//
|
||||
// The keystroke reader is rebuilt on the way back in: the one that was running
|
||||
// is reading a file descriptor that closing the terminal has taken away from
|
||||
// it, and its goroutine ends when that read fails.
|
||||
func (b *browser) runInTerminal(argv []string) error {
|
||||
if len(argv) == 0 {
|
||||
return errf("nothing to run")
|
||||
}
|
||||
b.close() // clears the screen, puts the cursor back, hands the tty back
|
||||
|
||||
release := holdTerminalSignals()
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err := cmd.Run()
|
||||
release()
|
||||
|
||||
if err != nil && !interrupted(err) {
|
||||
// Something to read: the message would be wiped by the next frame, so
|
||||
// the screen is held until somebody has seen it.
|
||||
PF("\n%s %v\n", Crb(argv[0]+":"), err)
|
||||
PF("%s", Cd("press enter to come back to gvm "))
|
||||
os.Stdin.Read(make([]byte, 1))
|
||||
}
|
||||
|
||||
// Coming back in. If the terminal cannot be taken again there is nothing
|
||||
// left to draw on, so it is said here, in the ordinary terminal that is
|
||||
// still on screen — and the loop ends on its own: the reader that was
|
||||
// running is reading a closed descriptor and reports that as a Ctrl-C.
|
||||
if oerr := b.open(); oerr != nil {
|
||||
PE("cannot take the terminal back", oerr.Error())
|
||||
return oerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
|
||||
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
|
||||
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
|
||||
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
|
||||
// the list one was going back to was not.
|
||||
//
|
||||
// They are caught rather than ignored, and the difference matters: exec resets a
|
||||
// caught signal to its default in the child, while an ignored one is inherited.
|
||||
// An ssh that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering would be worse than what this fixes.
|
||||
//
|
||||
// The returned func puts them back the way they were, which is gvm's own raw
|
||||
// mode reading Ctrl-C as a key like any other.
|
||||
func holdTerminalSignals() func() {
|
||||
// Buffered and never read: the signal package sends without blocking and
|
||||
// drops what does not fit, which is the whole intent — these are being
|
||||
// swallowed, not handled.
|
||||
ch := make(chan os.Signal, 4)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
|
||||
return func() { signal.Stop(ch) }
|
||||
}
|
||||
|
||||
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
|
||||
// Ctrl-\) rather than because something went wrong. Nothing is held on the
|
||||
// screen for it: the person who pressed it knows what happened and wants to be
|
||||
// back in the list, not reading that ssh got a signal.
|
||||
func interrupted(err error) bool {
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
return false
|
||||
}
|
||||
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
|
||||
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
|
||||
}
|
||||
// A shell between gvm and the signal reports it as its own exit status
|
||||
// instead, in the shells' 128+signal spelling.
|
||||
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The name the guest calls itself is what is in the known hosts file; the
|
||||
// address is the fallback, and a machine whose guest says nothing has neither.
|
||||
func TestSSHTarget(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := r.sshTarget(); got != "web01.example" {
|
||||
t.Errorf("sshTarget = %q, want the hostname", got)
|
||||
}
|
||||
|
||||
r.vm.Guest.HostName = ""
|
||||
if got := r.sshTarget(); got != "10.0.0.5" {
|
||||
t.Errorf("with no hostname, sshTarget = %q", got)
|
||||
}
|
||||
|
||||
r.vm.Guest = nil
|
||||
if got := r.sshTarget(); got != "" {
|
||||
t.Errorf("with no guest information at all, sshTarget = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
template string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
|
||||
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
|
||||
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
|
||||
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
|
||||
{"mosh %h", []string{"mosh", "web01"}},
|
||||
{"ssh root@%h", []string{"ssh", "root@web01"}},
|
||||
} {
|
||||
if got := sshCommand(c.template, "web01"); !slices.Equal(got, c.want) {
|
||||
t.Errorf("sshCommand(%q) = %v, want %v", c.template, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A clipboard is invisible, so what the status line says about it has to be
|
||||
// exact: which of the two the machine gave up — the hostname or the address —
|
||||
// and which clipboard it went into.
|
||||
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
|
||||
// Pretending to be an ssh login does two things: it is the case where the
|
||||
// escape sequence is the only route, and it keeps the tests off the
|
||||
// clipboard of whoever is running them.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
row func() vmRow
|
||||
want []string
|
||||
}{
|
||||
{"a guest that reports its name", func() vmRow {
|
||||
return testRow("web01", true, "10.0.0.5")
|
||||
}, []string{"hostname", "web01.example"}},
|
||||
{"a guest that reports only an address", func() vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.HostName = ""
|
||||
return r
|
||||
}, []string{"address", "10.0.0.5"}},
|
||||
} {
|
||||
r := c.row()
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = pw
|
||||
b.copyAddress(r)
|
||||
pw.Close()
|
||||
sent, _ := io.ReadAll(pr)
|
||||
pr.Close()
|
||||
|
||||
for _, want := range c.want {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
|
||||
}
|
||||
}
|
||||
// And the sequence carried the same string, base64 and all.
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
|
||||
if !strings.Contains(string(sent), payload) {
|
||||
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
|
||||
// far end of a login copies into the clipboard of a machine nobody is sitting
|
||||
// at, and the terminal's own sequence is the only route home.
|
||||
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
|
||||
t.Setenv("SSH_CONNECTION", "")
|
||||
t.Setenv("SSH_TTY", "/dev/ttys004")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the tool is, it takes the text on standard input and gets it verbatim
|
||||
// — no trailing newline, which in a clipboard turns a pasted hostname into a
|
||||
// pasted hostname and a return.
|
||||
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "clipboard")
|
||||
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
|
||||
t.Fatalf("runClipTool: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "web01.example" {
|
||||
t.Errorf("the clipboard would get %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
|
||||
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
|
||||
// reads but a SIGINT to the whole foreground process group, and gvm is in that
|
||||
// group. While a child has the screen the signal has to be caught and dropped:
|
||||
// were it not, this test would kill the test binary rather than fail.
|
||||
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
|
||||
release := holdTerminalSignals()
|
||||
defer release()
|
||||
|
||||
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
|
||||
if err := syscall.Kill(os.Getpid(), sig); err != nil {
|
||||
t.Fatalf("cannot send myself a %v: %v", sig, err)
|
||||
}
|
||||
}
|
||||
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// And the child must still die of it, which is why the signal is caught and
|
||||
// not ignored: exec resets a caught signal to its default in the child,
|
||||
// while an ignored one is inherited — signal.Ignore here would leave an ssh
|
||||
// that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering.
|
||||
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
|
||||
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
|
||||
}
|
||||
}
|
||||
|
||||
// And a child that died of that keystroke is told apart from one that failed, so
|
||||
// the screen is not held with "signal: interrupt" over something somebody meant
|
||||
// to do.
|
||||
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
script string
|
||||
want bool
|
||||
}{
|
||||
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
|
||||
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
|
||||
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
|
||||
{"exit 1", false}, // a remote command that failed
|
||||
{"exit 255", false}, // ssh's own "could not connect"
|
||||
{"exit 0", false}, // nothing wrong at all
|
||||
} {
|
||||
err := exec.Command("sh", "-c", c.script).Run()
|
||||
if got := interrupted(err); got != c.want {
|
||||
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Something that never got as far as a child at all is not an interruption.
|
||||
if interrupted(errf("nothing to run")) {
|
||||
t.Error("a plain error was taken for a Ctrl-C")
|
||||
}
|
||||
}
|
||||
|
||||
// The target is one argument and never a piece of a shell command. It comes
|
||||
// from the guest — a name the guest chose for itself — so a machine that called
|
||||
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
|
||||
// as a command that runs.
|
||||
func TestTheTargetIsNeverShellCode(t *testing.T) {
|
||||
nasty := "; rm -rf ~"
|
||||
got := sshCommand("ssh -l root %h", nasty)
|
||||
|
||||
if len(got) != 4 || got[3] != nasty {
|
||||
t.Fatalf("the target was taken apart: %v", got)
|
||||
}
|
||||
for _, arg := range got[:3] {
|
||||
if strings.Contains(arg, "rm") {
|
||||
t.Errorf("the target leaked into %q", arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The link is the H5 client's own shape: the object's reference and the
|
||||
// vCenter's instance UUID, which is the serverGuid that client wants.
|
||||
func TestVsphereLink(t *testing.T) {
|
||||
got := vsphereLink("https://v308.example/", "vm-42", "6ff1a05e-1111")
|
||||
want := "https://v308.example/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:vm-42:6ff1a05e-1111/summary"
|
||||
if got != want {
|
||||
t.Errorf("vsphereLink =\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
|
||||
// A missing piece gives no link rather than a wrong one: a link that opens
|
||||
// a client saying "object not found" looks like a vCenter fault.
|
||||
for _, c := range [][3]string{
|
||||
{"", "vm-42", "guid"}, {"https://v308.example", "", "guid"}, {"https://v308.example", "vm-42", ""},
|
||||
} {
|
||||
if got := vsphereLink(c[0], c[1], c[2]); got != "" {
|
||||
t.Errorf("vsphereLink(%q, %q, %q) = %q, want nothing", c[0], c[1], c[2], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that was read over a connection that has gone has no link.
|
||||
func TestVsphereURLNeedsTheConnection(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := vsphereURL(r); got != "" {
|
||||
t.Errorf("a row with no session produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The clipboard is the terminal's, not the machine's: gvm is run over ssh as
|
||||
// often as not, and pbcopy would then copy into a clipboard nobody is looking
|
||||
// at. This is the escape sequence that asks the terminal itself.
|
||||
func TestOsc52CarriesTheTextItself(t *testing.T) {
|
||||
got := osc52("10.0.0.5")
|
||||
if !strings.HasPrefix(got, "\x1b]52;c;") || !strings.HasSuffix(got, "\a") {
|
||||
t.Fatalf("osc52 = %q", got)
|
||||
}
|
||||
payload := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]52;c;"), "\a")
|
||||
back, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("the payload is not base64: %v", err)
|
||||
}
|
||||
if string(back) != "10.0.0.5" {
|
||||
t.Errorf("the clipboard would get %q", back)
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet says why a key cannot do anything rather than swallowing it, and the
|
||||
// two that need somewhere to connect to say exactly that.
|
||||
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest = nil
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
|
||||
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
for _, k := range []rune{'h', 'y'} {
|
||||
b.setStatus("", "")
|
||||
b.detailRune(k)
|
||||
if !strings.Contains(b.status, "no address") {
|
||||
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// With an address there is nothing to object to. Only the check is asked
|
||||
// here — what follows it is an ssh session and a clipboard.
|
||||
b.rows[0] = testRow("web01", true, "10.0.0.5")
|
||||
if !b.hasAddress(b.rows[0]) {
|
||||
t.Errorf("a machine with an address was refused: %s", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Every letter in the menu reaches exactly one entry, or one of them is
|
||||
// unreachable — and the snapshot half must not have taken a letter the
|
||||
// power half already uses.
|
||||
func TestMenuLettersAreDistinct(t *testing.T) {
|
||||
b := &browser{}
|
||||
seen := map[rune]string{}
|
||||
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,16 +80,12 @@ 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.Bool(&yes, "y", "yes", "Do not ask before removing snapshots")
|
||||
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
|
||||
var vmMatch string
|
||||
@@ -64,15 +94,42 @@ 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")
|
||||
|
||||
var snapLs, snapNew_, snapRm, snapRmAll, snapName string
|
||||
// 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>")
|
||||
subSnap.String(&snapNew_, "n", "new", "Take a snapshot of <vm>")
|
||||
subSnap.String(&snapName, "s", "snapshot", "Snapshot name, for --remove and --revert")
|
||||
subSnap.String(&snapRm, "r", "remove", "Remove one snapshot of <vm>, named with -s")
|
||||
subSnap.String(&snapName, "s", "snapshot", "Snapshot name for -r")
|
||||
subSnap.String(&snapRmAll, "R", "removeall", "Remove all snapshots of <vm>")
|
||||
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")
|
||||
subPower.Description = "Start, stop and reboot machines"
|
||||
subPower.String(&pwOn, "o", "on", "Power on <vm>")
|
||||
subPower.String(&pwShutdown, "s", "shutdown", "Ask the guest of <vm> to shut down (needs VMware Tools)")
|
||||
subPower.String(&pwReboot, "b", "reboot", "Ask the guest of <vm> to reboot (needs VMware Tools)")
|
||||
subPower.String(&pwOff, "", "off", "Power off <vm> at the hypervisor — hard, like pulling the plug")
|
||||
subPower.String(&pwReset, "", "reset", "Reset <vm> at the hypervisor — hard, like the reset button")
|
||||
|
||||
var hostCount, hostTelemetry bool
|
||||
subHost := flaggy.NewSubcommand("host")
|
||||
@@ -80,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")
|
||||
@@ -88,15 +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()
|
||||
@@ -107,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
|
||||
@@ -127,11 +223,48 @@ func run() error {
|
||||
return errf("snap -r needs the snapshot to remove: gvm snap -r %s -s <snapshot>", snapRm)
|
||||
}
|
||||
return snapRemove(vc, snapRm, snapName, yes)
|
||||
case snapRevertTo != "":
|
||||
if snapName == "" {
|
||||
return errf("snap --revert needs the snapshot to go back to: gvm snap --revert %s -s <snapshot>", snapRevertTo)
|
||||
}
|
||||
return snapRevert(vc, snapRevertTo, snapName, yes)
|
||||
case snapRmAll != "":
|
||||
return snapRemoveAll(vc, snapRmAll, yes)
|
||||
}
|
||||
flaggy.ShowHelpAndExit("")
|
||||
|
||||
case subPower.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// One at a time. Two power options in one command line is a mistake, not
|
||||
// a sequence, and guessing which was meant is not something to do to a
|
||||
// running machine.
|
||||
given := []struct {
|
||||
vm string
|
||||
op powerOp
|
||||
}{}
|
||||
for _, c := range []struct {
|
||||
vm string
|
||||
op powerOp
|
||||
}{
|
||||
{pwOn, opPowerOn}, {pwShutdown, opShutdownGuest}, {pwReboot, opRebootGuest},
|
||||
{pwOff, opPowerOff}, {pwReset, opReset},
|
||||
} {
|
||||
if c.vm != "" {
|
||||
given = append(given, c)
|
||||
}
|
||||
}
|
||||
switch len(given) {
|
||||
case 1:
|
||||
return powerCLI(vc, given[0].vm, given[0].op, yes)
|
||||
case 0:
|
||||
flaggy.ShowHelpAndExit("")
|
||||
default:
|
||||
return errf("one power operation at a time, not %d", len(given))
|
||||
}
|
||||
|
||||
case subHost.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
@@ -140,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("")
|
||||
@@ -163,6 +304,9 @@ func run() error {
|
||||
return vmlog(cfg, vc, logMinutes, logMail)
|
||||
|
||||
case subConfig.Used:
|
||||
if cfgPassword != "" {
|
||||
return setPassword(cfg, cfgPassword)
|
||||
}
|
||||
return showConfig(cfg)
|
||||
}
|
||||
|
||||
@@ -177,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
|
||||
@@ -196,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)
|
||||
@@ -208,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 "-"
|
||||
@@ -219,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":
|
||||
@@ -241,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
@@ -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
@@ -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,
|
||||
|
||||
@@ -35,40 +35,36 @@ func hoststat(vc VCenter, telemetry string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
P()
|
||||
P("Name CPU Used Mem Total Mem VM ON Status Connected")
|
||||
|
||||
printRow(hostColumns, "", nil) // the heading, from the same widths as the rows
|
||||
for _, host := range hosts {
|
||||
hn := shortHost(host.Name)
|
||||
total := len(host.Vm)
|
||||
on := countOn(host.Vm, running)
|
||||
|
||||
var usedMem, totalMem int64
|
||||
usedMem = int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
cpustr := " -"
|
||||
usedMem := int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
totalMem := int64(0)
|
||||
cpu, cpuKnown := 0.0, false
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
totalMem = hw.MemorySize
|
||||
if cpu, ok := cpuPercent(hw, host.Summary.QuickStats); ok {
|
||||
cpustr = colorPercent(cpu)
|
||||
}
|
||||
cpu, cpuKnown = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
cpuText, cpuCol := "-", colOff
|
||||
if cpuKnown {
|
||||
cpuText, cpuCol = SF("%.2f", cpu), loadColor(cpu, true)
|
||||
}
|
||||
|
||||
PF("%4s %s %8s %8s %3d %3d %6s %s\n",
|
||||
Cwb(hn),
|
||||
cpustr,
|
||||
units.ByteSize(usedMem),
|
||||
units.ByteSize(totalMem),
|
||||
total,
|
||||
on,
|
||||
colorStatus(host.OverallStatus),
|
||||
host.Runtime.ConnectionState,
|
||||
)
|
||||
printRow(hostColumns, "", []cell{
|
||||
{hn, cWhite.fg()},
|
||||
{cpuText, cpuCol},
|
||||
{units.ByteSize(usedMem).String(), colSize},
|
||||
{units.ByteSize(totalMem).String(), colSize},
|
||||
{Itoa(total), colSize},
|
||||
{Itoa(on), colSize},
|
||||
{string(host.OverallStatus), statusColor(host.OverallStatus)},
|
||||
{string(host.Runtime.ConnectionState), colAside},
|
||||
})
|
||||
|
||||
if telemetry != "" {
|
||||
cpu := 0.0
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
cpu, _ = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
post(telemetry, SF("vm,%s,%.2f,%d,%d,%d,%d,%s,%s",
|
||||
hn, cpu, usedMem, totalMem, total, on,
|
||||
host.OverallStatus, host.Runtime.ConnectionState))
|
||||
@@ -95,10 +91,13 @@ func vmstat(vc VCenter) error {
|
||||
return err
|
||||
}
|
||||
|
||||
PF("%-25s %-10s %-10s\n", "Host", "Total VMs", "PoweredOn")
|
||||
P(SR("-", 47))
|
||||
printRow(countColumns, "", nil)
|
||||
for _, host := range hosts {
|
||||
PF("%-25s %-10d %-10d\n", host.Name, len(host.Vm), countOn(host.Vm, running))
|
||||
printRow(countColumns, "", []cell{
|
||||
{host.Name, cWhite.fg()},
|
||||
{Itoa(len(host.Vm)), colSize},
|
||||
{Itoa(countOn(host.Vm, running)), colSize},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -143,30 +142,17 @@ func cpuPercent(hw *types.HostHardwareSummary, qs types.HostListSummaryQuickStat
|
||||
return 100.0 / float64(totalMHz) * float64(qs.OverallCpuUsage), true
|
||||
}
|
||||
|
||||
func colorPercent(p float64) string {
|
||||
s := SF("%6.2f", p)
|
||||
switch {
|
||||
case p > 90:
|
||||
return Crb(s)
|
||||
case p > 50:
|
||||
return Cyb(s)
|
||||
case p > 2:
|
||||
return Cwb(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func colorStatus(st types.ManagedEntityStatus) string {
|
||||
s := string(st)
|
||||
switch s {
|
||||
// statusColor: vSphere's own words for how a host is doing, in the palette's.
|
||||
func statusColor(st types.ManagedEntityStatus) string {
|
||||
switch string(st) {
|
||||
case "green":
|
||||
return Cgb(s)
|
||||
return colOK
|
||||
case "yellow":
|
||||
return Cyb(s)
|
||||
return colBusy
|
||||
case "red":
|
||||
return Crb(s)
|
||||
return colFull
|
||||
}
|
||||
return s
|
||||
return colAside
|
||||
}
|
||||
|
||||
// shortHost is the hostname without its domain, which is all the first column
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// issues.go — the machines that want looking at.
|
||||
//
|
||||
// A list of two hundred machines is read by running the eye down it, which is
|
||||
// exactly the wrong way to find the three that are broken: a lost VMware Tools,
|
||||
// a filesystem at 97 %, a snapshot from March, a machine sitting on a question
|
||||
// nobody has answered. Every one of those facts is already in the inventory
|
||||
// sweep and none of them is visible in a table sorted by name.
|
||||
//
|
||||
// So this is not a new question put to the vCenters — it is a filter over the
|
||||
// answer they have already given (^i in the list, `vm -l --issues` on the
|
||||
// command line), and each machine carries the reason it is in the list.
|
||||
//
|
||||
// What counts as an issue is deliberately narrow. A list that cries wolf is one
|
||||
// nobody opens, so a machine that is switched off is not an issue, a machine
|
||||
// without VMware Tools is only worth a word while it is running, and a snapshot
|
||||
// is only old once it has stopped being anybody's afternoon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// When a guest filesystem is worth naming. Ninety per cent is where a disk
|
||||
// stops having room for a surprise; ninety-five is where it stops having room.
|
||||
// Small partitions sit legitimately close to full — /boot on a Debian is a
|
||||
// perennial 92 % — which is why the mount point is always named with the figure
|
||||
// rather than the machine merely being flagged.
|
||||
const (
|
||||
fsWarnPct = 90.0
|
||||
fsBadPct = 95.0
|
||||
)
|
||||
|
||||
// issue is one reason a machine is in the list. bad separates "this is broken"
|
||||
// from "this wants a look" — the colours of the two are the palette's red and
|
||||
// yellow, and the order they are reported in is worst first, because the column
|
||||
// they end up in is the one that gets truncated.
|
||||
type issue struct {
|
||||
text string
|
||||
bad bool
|
||||
}
|
||||
|
||||
// issueList is everything gvm has to say against this machine, worst first.
|
||||
//
|
||||
// Everything here is read off the row as the sweep left it. Nothing in this
|
||||
// function may go to the network: it is called for every machine in the
|
||||
// inventory, for the table, for the filter and for the report.
|
||||
func (r vmRow) issueList() []issue {
|
||||
var bad, warn []issue
|
||||
add := func(isBad bool, format string, a ...any) {
|
||||
i := issue{text: SF(format, a...), bad: isBad}
|
||||
if isBad {
|
||||
bad = append(bad, i)
|
||||
return
|
||||
}
|
||||
warn = append(warn, i)
|
||||
}
|
||||
|
||||
rt := r.vm.Summary.Runtime
|
||||
|
||||
// vCenter cannot see the machine properly. Everything below this line is a
|
||||
// statement about a machine vSphere is in touch with, so this comes first.
|
||||
switch rt.ConnectionState {
|
||||
case types.VirtualMachineConnectionStateConnected, "":
|
||||
default:
|
||||
add(true, "%s", string(rt.ConnectionState))
|
||||
}
|
||||
|
||||
// A machine stopped on a question is stopped until somebody answers it, and
|
||||
// nothing in the ordinary table says so.
|
||||
if rt.Question != nil {
|
||||
add(true, "waiting for an answer in vCenter")
|
||||
}
|
||||
|
||||
// Delta disks left behind by a snapshot removal that did not finish. The
|
||||
// machine runs perfectly well and grows quietly until the datastore is full.
|
||||
if rt.ConsolidationNeeded {
|
||||
add(true, "disks need consolidating")
|
||||
}
|
||||
|
||||
// What vCenter itself is complaining about. Its own alarms are the best
|
||||
// answer to "is something wrong", so they are passed on rather than
|
||||
// second-guessed — by the name a person gave the alarm, never by its number.
|
||||
alarms := 0
|
||||
for _, a := range r.vm.TriggeredAlarmState {
|
||||
if a.Acknowledged != nil && *a.Acknowledged {
|
||||
continue // somebody has seen it and said so
|
||||
}
|
||||
switch a.OverallStatus {
|
||||
case types.ManagedEntityStatusRed:
|
||||
add(true, "alarm: %s", r.alarmLabel(a.Alarm))
|
||||
alarms++
|
||||
case types.ManagedEntityStatusYellow:
|
||||
add(false, "alarm: %s", r.alarmLabel(a.Alarm))
|
||||
alarms++
|
||||
}
|
||||
}
|
||||
// The overall status is the rollup of those alarms. It is only worth a line
|
||||
// of its own when no alarm came with it — otherwise the same fact would be
|
||||
// reported twice, once with a reason and once without.
|
||||
if alarms == 0 {
|
||||
switch r.vm.Summary.OverallStatus {
|
||||
case types.ManagedEntityStatusRed:
|
||||
add(true, "vCenter says red")
|
||||
case types.ManagedEntityStatusYellow:
|
||||
add(false, "vCenter says yellow")
|
||||
}
|
||||
}
|
||||
|
||||
if r.running() {
|
||||
if !r.toolsRunning() {
|
||||
add(false, "no VMware Tools")
|
||||
}
|
||||
for _, d := range r.fullDisks() {
|
||||
add(d.pct >= fsBadPct, "%s %.0f %% full", d.path, d.pct)
|
||||
}
|
||||
}
|
||||
|
||||
// An old snapshot is the one issue here that is nobody's fault and
|
||||
// everybody's job. The table's colours change at a week; the report only
|
||||
// names one once it is a month old, so this list stays worth reading.
|
||||
if e, ok := r.oldest(); ok {
|
||||
if days := e.days(); days >= snapOldDays {
|
||||
add(false, "snapshot %s is %s old", e.name, plural(days, "day"))
|
||||
}
|
||||
}
|
||||
|
||||
return append(bad, warn...)
|
||||
}
|
||||
|
||||
// fullDisk is one guest filesystem that is nearly full.
|
||||
type fullDisk struct {
|
||||
path string
|
||||
pct float64
|
||||
}
|
||||
|
||||
// fullDisks are the guest's filesystems worth naming. The figures come from
|
||||
// VMware Tools, so a machine without it simply has none — which is not the same
|
||||
// as having none that are full, and is why the absence of Tools is its own line.
|
||||
func (r vmRow) fullDisks() []fullDisk {
|
||||
g := r.vm.Guest
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
var out []fullDisk
|
||||
for _, d := range g.Disk {
|
||||
if d.Capacity <= 0 {
|
||||
continue
|
||||
}
|
||||
pct := 100.0 - 100.0/float64(d.Capacity)*float64(d.FreeSpace)
|
||||
if pct >= fsWarnPct {
|
||||
out = append(out, fullDisk{path: d.DiskPath, pct: pct})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// alarmLabel is the alarm's own name, or its reference when the names could not
|
||||
// be read. "alarm-14 is red" is not something anybody can act on, but it is
|
||||
// still better than not saying that something is.
|
||||
func (r vmRow) alarmLabel(ref types.ManagedObjectReference) string {
|
||||
if r.sess != nil {
|
||||
if name := r.sess.alarms[ref]; name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ref.Value
|
||||
}
|
||||
|
||||
// issues is the reasons as plain text, worst first.
|
||||
func (r vmRow) issues() []string {
|
||||
list := r.issueList()
|
||||
out := make([]string, 0, len(list))
|
||||
for _, i := range list {
|
||||
out = append(out, i.text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r vmRow) hasIssues() bool { return len(r.issueList()) > 0 }
|
||||
|
||||
// issueCell is the WHY column: every reason, worst first, in one line for the
|
||||
// column to truncate from the right. Truncation is why the order matters.
|
||||
func (r vmRow) issueCell() string { return strings.Join(r.issues(), " · ") }
|
||||
|
||||
// issueColor paints the row's worst reason: red where something is broken,
|
||||
// yellow where something wants a look.
|
||||
func (r vmRow) issueColor() string {
|
||||
for _, i := range r.issueList() {
|
||||
if i.bad {
|
||||
return colFull
|
||||
}
|
||||
}
|
||||
if len(r.issueList()) > 0 {
|
||||
return colBusy
|
||||
}
|
||||
return colOff
|
||||
}
|
||||
|
||||
// withIssues is the filter itself.
|
||||
func withIssues(rows []vmRow) []vmRow {
|
||||
out := make([]vmRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.hasIssues() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// aged builds a snapshot entry that was taken so many days ago.
|
||||
func aged(name string, days int) snapEntry {
|
||||
when := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
return snapEntry{
|
||||
ref: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "snapshot-" + name},
|
||||
name: name,
|
||||
when: when,
|
||||
created: when.Local().Format("02.01.2006 15:04"),
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with nothing wrong with it says nothing. This is the one that
|
||||
// matters: the whole point of the filter is that it is short.
|
||||
func TestAHealthyMachineHasNoIssues(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a healthy machine reported %v", got)
|
||||
}
|
||||
if r.hasIssues() {
|
||||
t.Error("a healthy machine is in the issues list")
|
||||
}
|
||||
if r.issueColor() != colOff {
|
||||
t.Error("a healthy machine's reason is coloured as though it had one")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that is switched off is not a fault, and the things that are only
|
||||
// true of a running machine are not held against a stopped one.
|
||||
func TestAStoppedMachineIsNotAnIssue(t *testing.T) {
|
||||
r := testRow("web01", false, "10.0.0.5")
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100, FreeSpace: 1}}
|
||||
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a stopped machine reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesFound(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
bend func(*vmRow)
|
||||
want string
|
||||
bad bool
|
||||
}{
|
||||
{"disconnected", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateDisconnected
|
||||
}, "disconnected", true},
|
||||
{"orphaned", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateOrphaned
|
||||
}, "orphaned", true},
|
||||
{"a question", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.Question = &types.VirtualMachineQuestionInfo{Id: "1"}
|
||||
}, "waiting for an answer", true},
|
||||
{"consolidation", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
}, "consolidating", true},
|
||||
{"a red status", func(r *vmRow) {
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
|
||||
}, "vCenter says red", true},
|
||||
{"a yellow status", func(r *vmRow) {
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusYellow
|
||||
}, "vCenter says yellow", false},
|
||||
{"no tools", func(r *vmRow) {
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: "guestToolsNotRunning"}
|
||||
}, "no VMware Tools", false},
|
||||
{"a full filesystem", func(r *vmRow) {
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/var", Capacity: 100 << 30, FreeSpace: 3 << 30}}
|
||||
}, "/var 97 % full", true},
|
||||
{"a nearly full filesystem", func(r *vmRow) {
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/boot", Capacity: 100 << 30, FreeSpace: 8 << 30}}
|
||||
}, "/boot 92 % full", false},
|
||||
{"an old snapshot", func(r *vmRow) {
|
||||
r.snaps = []snapEntry{aged("before-patch", 63)}
|
||||
}, "before-patch is 63 days old", false},
|
||||
} {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
c.bend(&r)
|
||||
|
||||
list := r.issueList()
|
||||
found := false
|
||||
for _, i := range list {
|
||||
if strings.Contains(i.text, c.want) {
|
||||
found = true
|
||||
if i.bad != c.bad {
|
||||
t.Errorf("%s: bad = %v, want %v (%q)", c.what, i.bad, c.bad, i.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: nothing said %q, only %v", c.what, c.want, r.issues())
|
||||
}
|
||||
if !r.hasIssues() {
|
||||
t.Errorf("%s: the machine is not in the issues list", c.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A filesystem that is merely fairly full is nobody's business.
|
||||
func TestAFilesystemWithRoomIsNotReported(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100 << 30, FreeSpace: 20 << 30}}
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a filesystem at 80 %% reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot is only old once it has stopped being somebody's afternoon. The
|
||||
// table colours it yellow after a week; the list of things to answer for waits
|
||||
// for a month, or it fills up with this morning's work.
|
||||
func TestOnlyAMonthOldSnapshotIsAnIssue(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want bool
|
||||
}{{2, false}, {snapStaleDays + 1, false}, {snapOldDays, true}, {90, true}} {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.snaps = []snapEntry{aged("s", c.days)}
|
||||
if got := r.hasIssues(); got != c.want {
|
||||
t.Errorf("a snapshot of %d days: reported = %v, want %v (%v)",
|
||||
c.days, got, c.want, r.issues())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vCenter's own alarms are passed on by the name somebody gave them, and an
|
||||
// alarm that has been acknowledged has been dealt with by a person already.
|
||||
func TestAlarms(t *testing.T) {
|
||||
ref := types.ManagedObjectReference{Type: "Alarm", Value: "alarm-14"}
|
||||
yes := true
|
||||
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.TriggeredAlarmState = []types.AlarmState{
|
||||
{Alarm: ref, OverallStatus: types.ManagedEntityStatusRed},
|
||||
}
|
||||
// Without the names, the reference is still said: it is little use, but it
|
||||
// is not silence.
|
||||
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "alarm-14") {
|
||||
t.Errorf("an alarm with no name resolved reported %q", got)
|
||||
}
|
||||
|
||||
r.sess = &session{alarms: map[types.ManagedObjectReference]string{ref: "Host memory usage"}}
|
||||
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "Host memory usage") {
|
||||
t.Errorf("the alarm's name was not used: %q", got)
|
||||
}
|
||||
|
||||
r.vm.TriggeredAlarmState[0].Acknowledged = &yes
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("an acknowledged alarm still reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The rolled-up status is not reported next to the alarm it is the rollup of:
|
||||
// the same fact twice, once with a reason and once without.
|
||||
func TestTheStatusIsNotReportedTwice(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
|
||||
r.vm.TriggeredAlarmState = []types.AlarmState{{
|
||||
Alarm: types.ManagedObjectReference{Type: "Alarm", Value: "alarm-1"},
|
||||
OverallStatus: types.ManagedEntityStatusRed,
|
||||
}}
|
||||
|
||||
if got := r.issues(); len(got) != 1 {
|
||||
t.Errorf("a red machine with one alarm reported %d things: %v", len(got), got)
|
||||
}
|
||||
if got := strings.Join(r.issues(), " "); strings.Contains(got, "says red") {
|
||||
t.Errorf("the rollup was reported beside its own alarm: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Worst first, because the column they end up in is truncated from the right.
|
||||
func TestTheWorstReasonComesFirst(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // a word of warning
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true // broken
|
||||
|
||||
list := r.issueList()
|
||||
if len(list) < 2 {
|
||||
t.Fatalf("expected both reasons, got %v", r.issues())
|
||||
}
|
||||
if !list[0].bad {
|
||||
t.Errorf("the reasons came out warning first: %v", r.issues())
|
||||
}
|
||||
if r.issueColor() != colFull {
|
||||
t.Error("a machine with something broken is not painted as broken")
|
||||
}
|
||||
|
||||
// Only a warning: yellow, not red.
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = false
|
||||
if r.issueColor() != colBusy {
|
||||
t.Error("a machine with only a warning is painted as broken")
|
||||
}
|
||||
if cell := r.issueCell(); !strings.Contains(cell, "no VMware Tools") {
|
||||
t.Errorf("the reason column says %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithIssuesKeepsOnlyTheOnesToAnswerFor(t *testing.T) {
|
||||
good := testRow("web01", true, "10.0.0.5")
|
||||
bad := testRow("db01", true, "10.0.0.6")
|
||||
bad.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
|
||||
got := withIssues([]vmRow{good, bad})
|
||||
if len(got) != 1 || got[0].name != "db01" {
|
||||
t.Errorf("the filter kept %d machines: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// jsonout.go — the machine listing as a document.
|
||||
//
|
||||
// `gvm vm -l` is meant to be read; this is the same sweep meant to be parsed —
|
||||
// by a monitoring check, a report, a spreadsheet. So the shape here is a
|
||||
// promise, and two decisions follow from that.
|
||||
//
|
||||
// It is one object and not an array of machines, because a listing that leaves
|
||||
// out a vCenter which did not answer is worse than no listing at all: a script
|
||||
// handed a bare array cannot tell an empty cluster from an unreachable one. The
|
||||
// servers that answered and the ones that did not are part of the document.
|
||||
//
|
||||
// And a figure that is not known is null, never zero. A stopped machine has no
|
||||
// processor load, a machine whose guest is silent has no address, and a
|
||||
// spreadsheet that averages a column of zeroes reports a fleet that is idle.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jsonListing is the whole document.
|
||||
type jsonListing struct {
|
||||
Generated string `json:"generated"`
|
||||
Answered []string `json:"answered"`
|
||||
Failed []string `json:"failed"`
|
||||
Count int `json:"count"`
|
||||
Machines []jsonMachine `json:"machines"`
|
||||
}
|
||||
|
||||
// jsonMachine is one machine. The names are the ones the table's headers stand
|
||||
// for, spelled out: a document is read by somebody who cannot see the header.
|
||||
type jsonMachine struct {
|
||||
Name string `json:"name"`
|
||||
VCenter string `json:"vcenter"`
|
||||
Datacenter string `json:"datacenter"`
|
||||
Host string `json:"host"`
|
||||
|
||||
Power string `json:"power"`
|
||||
Connection string `json:"connection,omitempty"`
|
||||
Status string `json:"status,omitempty"` // vCenter's own green/yellow/red
|
||||
Template bool `json:"template"`
|
||||
|
||||
Address string `json:"address,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Guest string `json:"guest,omitempty"`
|
||||
ToolsRunning bool `json:"tools_running"`
|
||||
|
||||
CPUs int32 `json:"cpus"`
|
||||
CPUPercent *float64 `json:"cpu_percent"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
MemoryPercent *float64 `json:"memory_percent"`
|
||||
UptimeSeconds *int32 `json:"uptime_seconds"`
|
||||
|
||||
CommittedBytes *int64 `json:"committed_bytes"`
|
||||
UncommittedBytes *int64 `json:"uncommitted_bytes"`
|
||||
|
||||
Snapshots []jsonSnapshot `json:"snapshots"`
|
||||
OldestSnapshotAt *string `json:"oldest_snapshot_at"`
|
||||
OldestSnapshotDays *int `json:"oldest_snapshot_days"`
|
||||
|
||||
Task *jsonTask `json:"task"`
|
||||
Issues []string `json:"issues"`
|
||||
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Instance string `json:"instance_uuid,omitempty"`
|
||||
Moref string `json:"moref"`
|
||||
}
|
||||
|
||||
type jsonSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
Days int `json:"days"`
|
||||
Current bool `json:"current"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
type jsonTask struct {
|
||||
What string `json:"what"`
|
||||
Queued bool `json:"queued"`
|
||||
Progress int32 `json:"progress"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// printJSON writes the document. Indented, because the first reader of it is
|
||||
// always a person finding out what the keys are called.
|
||||
func printJSON(found sweep, rows []vmRow) error {
|
||||
doc := jsonListing{
|
||||
Generated: time.Now().Format(time.RFC3339),
|
||||
Answered: found.answered,
|
||||
Failed: found.failed,
|
||||
Count: len(rows),
|
||||
Machines: make([]jsonMachine, 0, len(rows)),
|
||||
}
|
||||
if doc.Answered == nil {
|
||||
doc.Answered = []string{}
|
||||
}
|
||||
if doc.Failed == nil {
|
||||
doc.Failed = []string{}
|
||||
}
|
||||
for _, r := range rows {
|
||||
doc.Machines = append(doc.Machines, jsonOf(r))
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
return errf("cannot write the listing: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonOf is one row as a document entry.
|
||||
func jsonOf(r vmRow) jsonMachine {
|
||||
sum := r.vm.Summary
|
||||
cfg := sum.Config
|
||||
rt := sum.Runtime
|
||||
|
||||
m := jsonMachine{
|
||||
Name: r.name,
|
||||
VCenter: r.vc.Name,
|
||||
Datacenter: r.vc.Datacenter,
|
||||
Host: r.host,
|
||||
Power: string(r.power()),
|
||||
Connection: string(rt.ConnectionState),
|
||||
Status: string(sum.OverallStatus),
|
||||
Template: cfg.Template,
|
||||
Guest: r.guestOS(),
|
||||
ToolsRunning: r.toolsRunning(),
|
||||
CPUs: cfg.NumCpu,
|
||||
MemoryMB: cfg.MemorySizeMB,
|
||||
Issues: r.issues(),
|
||||
UUID: cfg.Uuid,
|
||||
Instance: cfg.InstanceUuid,
|
||||
Moref: r.ref.Value,
|
||||
Snapshots: []jsonSnapshot{},
|
||||
}
|
||||
if m.Issues == nil {
|
||||
m.Issues = []string{}
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
m.Address = ip
|
||||
}
|
||||
if g := r.vm.Guest; g != nil {
|
||||
m.Hostname = g.HostName
|
||||
}
|
||||
if pct, ok := r.cpuLoad(); ok {
|
||||
m.CPUPercent = &pct
|
||||
}
|
||||
if pct, ok := r.memLoad(); ok {
|
||||
m.MemoryPercent = &pct
|
||||
}
|
||||
if r.running() && sum.QuickStats.UptimeSeconds > 0 {
|
||||
up := sum.QuickStats.UptimeSeconds
|
||||
m.UptimeSeconds = &up
|
||||
}
|
||||
if st := sum.Storage; st != nil {
|
||||
committed, uncommitted := st.Committed, st.Uncommitted
|
||||
m.CommittedBytes, m.UncommittedBytes = &committed, &uncommitted
|
||||
}
|
||||
for _, e := range r.snaps {
|
||||
m.Snapshots = append(m.Snapshots, jsonSnapshot{
|
||||
Name: e.name, Created: e.when.Format(time.RFC3339),
|
||||
Days: e.days(), Current: e.current, Depth: e.depth,
|
||||
})
|
||||
}
|
||||
if e, ok := r.oldest(); ok {
|
||||
at, days := e.when.Format(time.RFC3339), e.days()
|
||||
m.OldestSnapshotAt, m.OldestSnapshotDays = &at, &days
|
||||
}
|
||||
if t := r.task; t != nil {
|
||||
jt := jsonTask{What: t.what, Queued: t.queued, Progress: t.progress}
|
||||
if !t.since.IsZero() {
|
||||
jt.Since = t.since.Format(time.RFC3339)
|
||||
}
|
||||
m.Task = &jt
|
||||
}
|
||||
return m
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// jsonDoc runs the encoder over a listing and reads it back the way whatever is
|
||||
// on the other end of the pipe would.
|
||||
func jsonDoc(t *testing.T, found sweep, rows []vmRow) map[string]any {
|
||||
t.Helper()
|
||||
out := captureStdout(t, func() {
|
||||
if err := printJSON(found, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("what came out is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// A figure that is not known is null and never nought. A stopped machine has no
|
||||
// processor load, and a spreadsheet that averages a column of zeroes reports a
|
||||
// fleet that is idle.
|
||||
func TestJSONLeavesTheUnknownNull(t *testing.T) {
|
||||
on := testRow("web01", true, "10.0.0.5")
|
||||
off := testRow("db01", false, "")
|
||||
off.ref = types.ManagedObjectReference{Value: "vm-43"}
|
||||
|
||||
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{on, off})
|
||||
machines := doc["machines"].([]any)
|
||||
if len(machines) != 2 {
|
||||
t.Fatalf("the document holds %d machines", len(machines))
|
||||
}
|
||||
|
||||
running := machines[0].(map[string]any)
|
||||
stopped := machines[1].(map[string]any)
|
||||
|
||||
if running["cpu_percent"] == nil {
|
||||
t.Error("a running machine has no processor load in the document")
|
||||
}
|
||||
for _, key := range []string{"cpu_percent", "memory_percent", "uptime_seconds"} {
|
||||
if stopped[key] != nil {
|
||||
t.Errorf("a stopped machine reports %s = %v, want null", key, stopped[key])
|
||||
}
|
||||
}
|
||||
if stopped["address"] != nil {
|
||||
t.Errorf("a machine with no address reports address = %v", stopped["address"])
|
||||
}
|
||||
}
|
||||
|
||||
// The servers that answered and the ones that did not are part of the document.
|
||||
// A script handed a bare list of machines cannot tell an empty cluster from an
|
||||
// unreachable one, which is the difference that matters.
|
||||
func TestJSONNamesTheServersThatDidNotAnswer(t *testing.T) {
|
||||
doc := jsonDoc(t, sweep{
|
||||
answered: []string{"v308"},
|
||||
failed: []string{"v108: login failed"},
|
||||
}, nil)
|
||||
|
||||
if got := doc["answered"].([]any); len(got) != 1 || got[0] != "v308" {
|
||||
t.Errorf("answered = %v", got)
|
||||
}
|
||||
got := doc["failed"].([]any)
|
||||
if len(got) != 1 || !strings.Contains(got[0].(string), "v108") {
|
||||
t.Errorf("failed = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Both lists are always there, empty rather than absent: a reader that has to
|
||||
// tell null from [] is a reader that will get it wrong once.
|
||||
func TestJSONAlwaysHasBothServerLists(t *testing.T) {
|
||||
doc := jsonDoc(t, sweep{}, nil)
|
||||
for _, key := range []string{"answered", "failed", "machines"} {
|
||||
if doc[key] == nil {
|
||||
t.Errorf("%s is null in an empty listing", key)
|
||||
}
|
||||
}
|
||||
if doc["count"] != float64(0) {
|
||||
t.Errorf("count = %v", doc["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// What the table shows in colour and what the document says in words is the
|
||||
// same judgement, made in one place.
|
||||
func TestJSONCarriesTheIssuesAndTheSnapshots(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
r.snaps = []snapEntry{aged("before-patch", 63), aged("hotfix", 2)}
|
||||
r.task = &runningTask{what: "clone", progress: 40}
|
||||
|
||||
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{r})
|
||||
m := doc["machines"].([]any)[0].(map[string]any)
|
||||
|
||||
issues := m["issues"].([]any)
|
||||
if len(issues) == 0 || !strings.Contains(issues[0].(string), "consolidating") {
|
||||
t.Errorf("issues = %v", issues)
|
||||
}
|
||||
if got := m["snapshots"].([]any); len(got) != 2 {
|
||||
t.Errorf("the document holds %d snapshots", len(got))
|
||||
}
|
||||
if got := m["oldest_snapshot_days"]; got != float64(63) {
|
||||
t.Errorf("oldest_snapshot_days = %v", got)
|
||||
}
|
||||
task := m["task"].(map[string]any)
|
||||
if task["what"] != "clone" || task["progress"] != float64(40) {
|
||||
t.Errorf("task = %v", task)
|
||||
}
|
||||
|
||||
// And a machine with none of those says so, rather than leaving the reader
|
||||
// to guess whether the key was simply left out.
|
||||
quiet := testRow("db01", true, "10.0.0.6")
|
||||
doc = jsonDoc(t, sweep{}, []vmRow{quiet})
|
||||
m = doc["machines"].([]any)[0].(map[string]any)
|
||||
if got := m["issues"].([]any); len(got) != 0 {
|
||||
t.Errorf("a healthy machine reports issues = %v", got)
|
||||
}
|
||||
if m["task"] != nil {
|
||||
t.Errorf("an idle machine reports task = %v", m["task"])
|
||||
}
|
||||
if m["oldest_snapshot_at"] != nil {
|
||||
t.Errorf("a machine with no snapshots reports oldest_snapshot_at = %v", m["oldest_snapshot_at"])
|
||||
}
|
||||
}
|
||||
|
||||
// The document is one object, not a bare array: that is what leaves room for
|
||||
// the servers, and it is the promise a script is written against.
|
||||
func TestJSONIsOneDocument(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := printJSON(sweep{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("the document begins %q", strings.SplitN(out, "\n", 2)[0])
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,9 @@ func vmlog(cfg Config, vc VCenter, minutes int, mail bool) error {
|
||||
return fmt.Errorf("%s: cannot read the event log: %w", vc.Name, err)
|
||||
}
|
||||
|
||||
PF("%-20s | %-15s | %s\n", "Time", "Severity", "Message")
|
||||
P(SR("-", 80))
|
||||
// No heading and no rule: the lines below are comma-separated on purpose —
|
||||
// they are what the mail carries and what a script would cut up — and a
|
||||
// pipe-separated heading above them promised a table that never came.
|
||||
|
||||
msg := ""
|
||||
for _, e := range events {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
// power.go — starting, stopping and rebooting machines, and the checks that
|
||||
// come before any of it.
|
||||
//
|
||||
// Every operation here interrupts a running production machine, and two of them
|
||||
// can lose data outright. Three rules hold throughout:
|
||||
//
|
||||
// 1. Graceful and hard are different operations with different names, never a
|
||||
// fallback from one to the other. Asking the guest to shut down and pulling
|
||||
// its plug are not two ways of doing the same thing, and gvm never quietly
|
||||
// turns the first into the second because Tools did not answer.
|
||||
// 2. Nothing is sent that cannot work. A machine that is already off is not
|
||||
// shut down again, and a graceful operation is refused outright when VMware
|
||||
// Tools is not running, with the hard variant named in the message so the
|
||||
// operator chooses it deliberately rather than discovering it by accident.
|
||||
// 3. Waiting is bounded, and giving up waiting is not giving up the operation.
|
||||
// vCenter carries on with a task no matter what gvm does; the message says
|
||||
// so instead of implying the machine is in some half state.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// How long gvm waits for a task before it stops watching and says where to look
|
||||
// instead. These are not limits on the operation — vCenter has no idea gvm has
|
||||
// stopped waiting — they are limits on how long a terminal sits there frozen.
|
||||
const (
|
||||
powerWait = 5 * time.Minute
|
||||
// How long to wait for vCenter's own view of a machine to catch up with the
|
||||
// task that just finished. Normally this is over in well under a second; the
|
||||
// limit is only there so a server that never reports the change cannot hold
|
||||
// the screen.
|
||||
settleWait = 30 * time.Second
|
||||
snapshotWait = 30 * time.Minute
|
||||
// Removing a snapshot consolidates the disks, which on a large machine with
|
||||
// an old snapshot is genuinely an hour's work.
|
||||
consolidateWait = 60 * time.Minute
|
||||
)
|
||||
|
||||
// powerOp is one of the five things that can be done to a machine's power.
|
||||
type powerOp int
|
||||
|
||||
const (
|
||||
opPowerOn powerOp = iota
|
||||
opShutdownGuest
|
||||
opPowerOff
|
||||
opRebootGuest
|
||||
opReset
|
||||
)
|
||||
|
||||
// powerSpec is what each operation is, in words the operator has to read before
|
||||
// confirming. consequence is deliberately blunt: it is the last thing between a
|
||||
// keystroke and a production machine.
|
||||
type powerSpec struct {
|
||||
verb string // "power on"
|
||||
consequence string // what it does, in the confirmation
|
||||
graceful bool // goes through VMware Tools, so needs it running
|
||||
wantRunning bool // the machine has to be running for this to mean anything
|
||||
}
|
||||
|
||||
var powerSpecs = map[powerOp]powerSpec{
|
||||
opPowerOn: {
|
||||
verb: "power on",
|
||||
consequence: "starts the machine",
|
||||
},
|
||||
opShutdownGuest: {
|
||||
verb: "shut down the guest",
|
||||
consequence: "asks the operating system to shut down and stop the machine",
|
||||
graceful: true,
|
||||
wantRunning: true,
|
||||
},
|
||||
opPowerOff: {
|
||||
verb: "power off",
|
||||
consequence: "cuts the power at the hypervisor — like pulling the plug, " +
|
||||
"anything the guest has not written to disk is lost",
|
||||
wantRunning: true,
|
||||
},
|
||||
opRebootGuest: {
|
||||
verb: "reboot the guest",
|
||||
consequence: "asks the operating system to reboot",
|
||||
graceful: true,
|
||||
wantRunning: true,
|
||||
},
|
||||
opReset: {
|
||||
verb: "reset",
|
||||
consequence: "resets the machine at the hypervisor — like the reset button, " +
|
||||
"anything the guest has not written to disk is lost",
|
||||
wantRunning: true,
|
||||
},
|
||||
}
|
||||
|
||||
func (op powerOp) spec() powerSpec { return powerSpecs[op] }
|
||||
|
||||
// hardVariant names the operation to reach for when the graceful one is refused,
|
||||
// so the message can offer it by name instead of leaving the operator to guess.
|
||||
// settlesAt is the power state the machine ends up in, for the operations that
|
||||
// end in a definite one. A reset begins and ends powered on, so there is nothing
|
||||
// to wait for, and the two guest operations are requests the guest answers in its
|
||||
// own time — there is no moment for gvm to wait for at all.
|
||||
func (op powerOp) settlesAt() (types.VirtualMachinePowerState, bool) {
|
||||
switch op {
|
||||
case opPowerOn:
|
||||
return types.VirtualMachinePowerStatePoweredOn, true
|
||||
case opPowerOff:
|
||||
return types.VirtualMachinePowerStatePoweredOff, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (op powerOp) hardVariant() (powerOp, bool) {
|
||||
switch op {
|
||||
case opShutdownGuest:
|
||||
return opPowerOff, true
|
||||
case opRebootGuest:
|
||||
return opReset, true
|
||||
}
|
||||
return op, false
|
||||
}
|
||||
|
||||
// running reports whether the machine is powered on. Suspended counts as not
|
||||
// running: its guest is not executing, so nothing graceful can reach it.
|
||||
func (r vmRow) running() bool {
|
||||
return r.power() == types.VirtualMachinePowerStatePoweredOn
|
||||
}
|
||||
|
||||
// toolsRunning reports whether VMware Tools is answering in the guest, which is
|
||||
// what a graceful shutdown or reboot goes through. Scripts still executing count
|
||||
// as running — Tools is there, it is merely busy.
|
||||
func (r vmRow) toolsRunning() bool {
|
||||
status := ""
|
||||
if g := r.vm.Guest; g != nil {
|
||||
status = g.ToolsRunningStatus
|
||||
} else if g := r.vm.Summary.Guest; g != nil {
|
||||
status = g.ToolsRunningStatus
|
||||
}
|
||||
switch types.VirtualMachineToolsRunningStatus(status) {
|
||||
case types.VirtualMachineToolsRunningStatusGuestToolsRunning,
|
||||
types.VirtualMachineToolsRunningStatusGuestToolsExecutingScripts:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// powerObjection says why an operation cannot be carried out on this machine, in
|
||||
// two lengths: short enough for the menu's own column, and long enough to be
|
||||
// read on its own. Both come from here so the menu and the message can never
|
||||
// disagree about what is wrong.
|
||||
func powerObjection(r vmRow, op powerOp) (short, long string) {
|
||||
spec := op.spec()
|
||||
|
||||
switch {
|
||||
case spec.wantRunning && !r.running():
|
||||
return r.powerLong(), SF("%s is %s, not running", r.name, r.powerLong())
|
||||
|
||||
case op == opPowerOn && r.running():
|
||||
return "already running", SF("%s is already running", r.name)
|
||||
|
||||
case spec.graceful && !r.toolsRunning():
|
||||
hard, _ := op.hardVariant()
|
||||
return "no VMware Tools", SF("VMware Tools is not running on %s — %q acts "+
|
||||
"at the hypervisor instead", r.name, hard.spec().verb)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// checkPower is that objection as an error, or nil when there is none. It is
|
||||
// asked before the operation is offered and again before it is sent, so an
|
||||
// impossible one is never confirmed and never reaches vCenter.
|
||||
func checkPower(r vmRow, op powerOp) error {
|
||||
if _, long := powerObjection(r, op); long != "" {
|
||||
return errf("%s", long)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// powerLong is the power state in words, for the messages above.
|
||||
func (r vmRow) powerLong() string {
|
||||
switch r.power() {
|
||||
case types.VirtualMachinePowerStatePoweredOn:
|
||||
return "running"
|
||||
case types.VirtualMachinePowerStatePoweredOff:
|
||||
return "powered off"
|
||||
case types.VirtualMachinePowerStateSuspended:
|
||||
return "suspended"
|
||||
}
|
||||
return "in an unknown state"
|
||||
}
|
||||
|
||||
// runPower carries out the operation. It re-checks first: between the menu being
|
||||
// drawn and the confirmation being typed, the machine may well have changed state
|
||||
// under someone else's hands.
|
||||
//
|
||||
// The two graceful operations are requests to the guest, not tasks: vCenter
|
||||
// accepts them and the guest takes as long as it takes, so there is nothing to
|
||||
// wait for and the caller is told to look again in a moment. The three hard ones
|
||||
// are tasks, and those are waited for.
|
||||
func runPower(s *session, r vmRow, op powerOp) (message string, err error) {
|
||||
if err := checkPower(r, op); err != nil {
|
||||
return "", err
|
||||
}
|
||||
vm := object.NewVirtualMachine(s.client.Client, r.ref)
|
||||
spec := op.spec()
|
||||
|
||||
switch op {
|
||||
case opShutdownGuest:
|
||||
if err := vm.ShutdownGuest(s.ctx); err != nil {
|
||||
return "", errf("%s: cannot ask %s to shut down: %w", s.vc.Name, r.name, err)
|
||||
}
|
||||
return SF("%s was asked to shut down — the guest takes its own time; ^r when it has", r.name), nil
|
||||
|
||||
case opRebootGuest:
|
||||
if err := vm.RebootGuest(s.ctx); err != nil {
|
||||
return "", errf("%s: cannot ask %s to reboot: %w", s.vc.Name, r.name, err)
|
||||
}
|
||||
return SF("%s was asked to reboot — the guest takes its own time; ^r when it has", r.name), nil
|
||||
}
|
||||
|
||||
var task *object.Task
|
||||
switch op {
|
||||
case opPowerOn:
|
||||
task, err = vm.PowerOn(s.ctx)
|
||||
case opPowerOff:
|
||||
task, err = vm.PowerOff(s.ctx)
|
||||
case opReset:
|
||||
task, err = vm.Reset(s.ctx)
|
||||
default:
|
||||
return "", errf("unknown power operation %d", op) // unreachable, and loud if it ever is
|
||||
}
|
||||
if err != nil {
|
||||
return "", errf("%s: cannot %s %s: %w", s.vc.Name, spec.verb, r.name, err)
|
||||
}
|
||||
if err := waitTask(s.ctx, task, powerWait, SF("%s of %s", spec.verb, r.name)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// The task finishing and the inventory agreeing are two different moments.
|
||||
// vCenter reports the task done first and its property collector catches up
|
||||
// a little afterwards, so a machine re-read in between still comes back with
|
||||
// the state it had before — which is exactly what makes a list look as if it
|
||||
// had not noticed. Wait for the state itself, then the re-read is the truth.
|
||||
if want, ok := op.settlesAt(); ok {
|
||||
if err := settle(s.ctx, vm, want); err != nil {
|
||||
return SF("%s: %s done, but vCenter has not reported the new state yet — ^r in a moment",
|
||||
r.name, spec.verb), nil
|
||||
}
|
||||
}
|
||||
return SF("%s: %s done", r.name, spec.verb), nil
|
||||
}
|
||||
|
||||
// settle waits for the machine to actually be in the state given. It returns at
|
||||
// once when it already is: the underlying property waiter is handed the current
|
||||
// value before any change, so nothing is lost by asking after the fact.
|
||||
//
|
||||
// A timeout is not an error the caller should fail on — the operation itself
|
||||
// succeeded — so it only softens the message.
|
||||
func settle(ctx context.Context, vm *object.VirtualMachine, want types.VirtualMachinePowerState) error {
|
||||
wctx, cancel := context.WithTimeout(ctx, settleWait)
|
||||
defer cancel()
|
||||
return vm.WaitForPowerState(wctx, want)
|
||||
}
|
||||
|
||||
// waitTask waits for a vCenter task, but not forever.
|
||||
//
|
||||
// A timeout here stops gvm waiting; it does not stop the task. vCenter runs it to
|
||||
// the end regardless, so the message says where to look rather than suggesting
|
||||
// the operation was cancelled — telling an operator that a snapshot removal was
|
||||
// abandoned when it is in fact still consolidating would be worse than useless.
|
||||
func waitTask(ctx context.Context, task *object.Task, limit time.Duration, what string) error {
|
||||
wctx, cancel := context.WithTimeout(ctx, limit)
|
||||
defer cancel()
|
||||
|
||||
if err := task.Wait(wctx); err != nil {
|
||||
if errors.Is(wctx.Err(), context.DeadlineExceeded) {
|
||||
return errf("%s is taking longer than %s — gvm stopped waiting, "+
|
||||
"but vCenter is still running the task; check there", what, limit)
|
||||
}
|
||||
return errf("%s failed: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- the command line
|
||||
|
||||
// powerCLI is `gvm power`: connect, look at what the machine is doing now, say
|
||||
// what the operation would do, ask, and only then send it.
|
||||
func powerCLI(vc VCenter, vmname string, op powerOp, yes bool) error {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.close()
|
||||
|
||||
vm, err := s.vm(vmname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The state and the Tools status are read fresh rather than assumed, because
|
||||
// checkPower's answers are only worth anything if they describe the machine
|
||||
// as it is right now.
|
||||
var mvm mo.VirtualMachine
|
||||
if err := vm.Properties(s.ctx, vm.Reference(), []string{"summary", "guest"}, &mvm); err != nil {
|
||||
return errf("%s: cannot read the state of %s: %w", vc.Name, vm.Name(), err)
|
||||
}
|
||||
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: vm.Name(), vm: mvm}
|
||||
if h := mvm.Summary.Runtime.Host; h != nil {
|
||||
r.host = h.Value
|
||||
}
|
||||
|
||||
if err := checkPower(r, op); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := op.spec()
|
||||
var ok bool
|
||||
if op == opPowerOn {
|
||||
// Starting a machine destroys nothing; it gets the plain question that
|
||||
// snapshot creation gets, not the block with the warning in red.
|
||||
ok, err = confirm(SF("power on %s on %s?", r.name, vc.Name), yes)
|
||||
} else {
|
||||
ok, err = confirmDestructive(vc, spec.verb+" "+r.name,
|
||||
[][2]string{{"machine", r.name}, {"machine state", r.powerLong()}},
|
||||
spec.consequence, yes)
|
||||
}
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := runPower(s, r, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PO(msg)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// print.go — columns for the commands that print rather than draw.
|
||||
//
|
||||
// The one rule: pad first, colour afterwards. A colour is a handful of escape
|
||||
// bytes that occupy no columns on screen, but %8s counts them all the same — so a
|
||||
// coloured value handed to a width verb is never padded, and any value longer
|
||||
// than its column shoves everything after it to the right. `gvm host` did exactly
|
||||
// that: a host called DC0_C0_H0 shifted its whole line three columns against the
|
||||
// one above it, and the heading, being written by hand, lined up with neither.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// printColumn is one column of a printed table: what it is called, how wide it
|
||||
// is, and whether its values are numbers, which read better against the right.
|
||||
type printColumn struct {
|
||||
header string
|
||||
width int
|
||||
right bool
|
||||
}
|
||||
|
||||
// cell is one value with the colour it is shown in.
|
||||
type cell struct {
|
||||
text string
|
||||
col string
|
||||
}
|
||||
|
||||
var hostColumns = []printColumn{
|
||||
{header: "HOST", width: 10},
|
||||
{header: "CPU%", width: 6, right: true},
|
||||
{header: "USED MEM", width: 9, right: true},
|
||||
{header: "TOTAL MEM", width: 9, right: true},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
{header: "STATUS", width: 7},
|
||||
{header: "CONNECTED", width: 12},
|
||||
}
|
||||
|
||||
var countColumns = []printColumn{
|
||||
{header: "HOST", width: 24},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
}
|
||||
|
||||
// printRow writes one row, or the heading when cells is nil. Every value is
|
||||
// padded to its column as plain text and only then coloured, so a long value is
|
||||
// cut instead of pushing its neighbours along.
|
||||
func printRow(cols []printColumn, indent string, cells []cell) {
|
||||
out := make([]string, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
text, col := c.header, colHeader
|
||||
if cells != nil {
|
||||
if i >= len(cells) {
|
||||
break
|
||||
}
|
||||
text, col = cells[i].text, cells[i].col
|
||||
}
|
||||
out = append(out, paint(pad(text, c.width, c.right), col))
|
||||
}
|
||||
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
|
||||
}
|
||||
|
||||
// widen grows every column to the longest thing it holds, header included. For
|
||||
// output that is going somewhere without a width of its own, where cutting a
|
||||
// value would lose it rather than merely hide it.
|
||||
func widen(pcs []printColumn, body [][]cell) {
|
||||
for i := range pcs {
|
||||
w := len([]rune(pcs[i].header))
|
||||
for _, cells := range body {
|
||||
if i >= len(cells) {
|
||||
continue
|
||||
}
|
||||
if n := len([]rune(cells[i].text)); n > w {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
pcs[i].width = w
|
||||
}
|
||||
}
|
||||
|
||||
// pad fits text to width — truncating what is too long — against the left or,
|
||||
// for numbers, the right.
|
||||
func pad(text string, width int, right bool) string {
|
||||
t := truncate(text, width)
|
||||
gap := width - len([]rune(t))
|
||||
if gap <= 0 {
|
||||
return t
|
||||
}
|
||||
if right {
|
||||
return SR(" ", gap) + t
|
||||
}
|
||||
return t + SR(" ", gap)
|
||||
}
|
||||
|
||||
// paint colours text, but only where colour belongs. The escapes the interactive
|
||||
// screen uses are written straight out and would otherwise end up in a pipe;
|
||||
// fatih/color already works out whether that is the case, so its answer is the
|
||||
// one used here rather than a second opinion.
|
||||
func paint(text, col string) string {
|
||||
if col == "" || color.NoColor {
|
||||
return text
|
||||
}
|
||||
return col + text + attrOff
|
||||
}
|
||||
|
||||
// plainRow is one row with nothing in it but the values — the same cells and
|
||||
// the same widths as printRow writes to the screen, without the colour.
|
||||
//
|
||||
// For output that leaves the machine: the mail a report sends is read in a mail
|
||||
// client, where an escape sequence is not a colour but four stray characters,
|
||||
// and fatih/color's answer to "is this a terminal" is about this process's
|
||||
// stdout and says nothing about where a mail is going.
|
||||
func plainRow(cols []printColumn, cells []cell) string {
|
||||
out := make([]string, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
text := c.header
|
||||
if cells != nil {
|
||||
if i >= len(cells) {
|
||||
break
|
||||
}
|
||||
text = cells[i].text
|
||||
}
|
||||
out = append(out, pad(text, c.width, c.right))
|
||||
}
|
||||
return strings.TrimRight(strings.Join(out, " "), " ")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// seal.go — the passwords in ~/.gvmrc, not in plain sight.
|
||||
//
|
||||
// A sealed value looks like this, and the rest of the file stays as it was:
|
||||
//
|
||||
// vcenter.v308.password = gvmenc1:Lb2h…
|
||||
//
|
||||
// Only the value is sealed, never the file: urls, users, datacenters and the mail
|
||||
// settings stay readable and the file stays editable by hand, comments and all.
|
||||
// AES-256-GCM, the key derived per value with HKDF from a random salt, all of it
|
||||
// packed into one base64 word.
|
||||
//
|
||||
// What this is and is not, plainly. FILEKEY is compiled into gvm and is the same
|
||||
// in every copy of it, so whoever holds ~/.gvmrc *and* a gvm binary can open the
|
||||
// value; prising the key out is an afternoon's work, not a cluster's. This is not
|
||||
// a vault and it is not meant to be one. What it buys is that the password no
|
||||
// longer stands in the clear in a backup, in a home directory that syncs
|
||||
// somewhere, in an editor's swap file, or on a screen someone else is looking
|
||||
// at — which is what was asked for. The file stays 0600 for the rest.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FILEKEY is what the values in ~/.gvmrc are sealed under: thirty-two random
|
||||
// bytes, the same in every build so that a file written by one gvm opens in the
|
||||
// next. There is nothing to guess here and so no reason to slow a guesser down —
|
||||
// HKDF, not argon2, and a value opens in microseconds.
|
||||
//
|
||||
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
|
||||
// Values written by earlier builds then no longer open, and gvm says so and names
|
||||
// the vCenter whose password has to be entered again.
|
||||
var FILEKEY = "8Vb0MUm04VP/aOZTTSGcqdN9NbNC6CETAhSXu1hwbIk="
|
||||
|
||||
const (
|
||||
// sealTag marks a sealed value and leaves room to tell it apart from whatever
|
||||
// a later version writes, should the scheme ever have to change.
|
||||
sealTag = "gvmenc1:"
|
||||
saltLen = 16
|
||||
keyLen = 32
|
||||
sealInfo = "gvmrc password"
|
||||
)
|
||||
|
||||
// sealed reports whether a value is one, which is how gvm knows a password in the
|
||||
// file still stands in the clear and wants sealing.
|
||||
func sealed(value string) bool { return strings.HasPrefix(value, sealTag) }
|
||||
|
||||
// seal turns a password into the word that goes in the file.
|
||||
func seal(secret string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", errf("cannot seal the password: %w", err)
|
||||
}
|
||||
gcm, err := sealGCM(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", errf("cannot seal the password: %w", err)
|
||||
}
|
||||
|
||||
// salt, nonce and the sealed bytes travel together: opening it needs all
|
||||
// three and nothing else, so one word in the file is the whole story.
|
||||
blob := append(salt, nonce...)
|
||||
blob = gcm.Seal(blob, nonce, []byte(secret), nil)
|
||||
return sealTag + base64.StdEncoding.EncodeToString(blob), nil
|
||||
}
|
||||
|
||||
// unseal turns it back. A value that is not sealed comes back unchanged: that is
|
||||
// how a password typed straight into the file, or handed over in the environment,
|
||||
// keeps working.
|
||||
func unseal(value string) (string, error) {
|
||||
if !sealed(value) {
|
||||
return value, nil
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, sealTag))
|
||||
if err != nil {
|
||||
return "", errf("the sealed password is not readable: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := sealGCM(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < saltLen+gcm.NonceSize() {
|
||||
return "", errf("the sealed password is too short to be one")
|
||||
}
|
||||
salt, rest := blob[:saltLen], blob[saltLen:]
|
||||
nonce, box := rest[:gcm.NonceSize()], rest[gcm.NonceSize():]
|
||||
|
||||
gcm, err = sealGCM(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, err := gcm.Open(nil, nonce, box, nil)
|
||||
if err != nil {
|
||||
return "", errf("the sealed password does not open — it was sealed by a gvm " +
|
||||
"built with another key, or it has been altered; enter it again")
|
||||
}
|
||||
return string(secret), nil
|
||||
}
|
||||
|
||||
// sealGCM derives the key for one value and wraps it. A nil salt is allowed so a
|
||||
// caller may ask for the nonce size before it knows the salt.
|
||||
func sealGCM(salt []byte) (cipher.AEAD, error) {
|
||||
root, err := base64.StdEncoding.DecodeString(FILEKEY)
|
||||
if err != nil || len(root) == 0 {
|
||||
return nil, errf("this gvm was built without a usable key for sealing passwords")
|
||||
}
|
||||
key, err := hkdf.Key(sha256.New, root, salt, sealInfo, keyLen)
|
||||
if err != nil {
|
||||
return nil, errf("cannot derive the key: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, errf("cannot derive the key: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealRoundTrip(t *testing.T) {
|
||||
for _, secret := range []string{
|
||||
"hunter2",
|
||||
"",
|
||||
"mit Leerzeichen und Ümläuten",
|
||||
"a/b+c=d", // the characters base64 uses, to be sure nothing is confused
|
||||
"gvmenc1:nearly", // a password that looks like a sealed value
|
||||
strings.Repeat("x", 500),
|
||||
} {
|
||||
word, err := seal(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", secret, err)
|
||||
}
|
||||
if !sealed(word) {
|
||||
t.Errorf("%q sealed to something unmarked: %q", secret, word)
|
||||
}
|
||||
if secret != "" && strings.Contains(word, secret) {
|
||||
t.Errorf("%q is readable in its own sealed form: %q", secret, word)
|
||||
}
|
||||
back, err := unseal(word)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", secret, err)
|
||||
}
|
||||
if back != secret {
|
||||
t.Errorf("came back as %q, want %q", back, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh salt and nonce each time, so two machines with the same password do not
|
||||
// show the same word in the file — which would say they share one.
|
||||
func TestSealIsDifferentEveryTime(t *testing.T) {
|
||||
a, _ := seal("gleich")
|
||||
b, _ := seal("gleich")
|
||||
if a == b {
|
||||
t.Error("the same password sealed twice gave the same word")
|
||||
}
|
||||
}
|
||||
|
||||
// A value that is not sealed is handed back as it is: that is how a password
|
||||
// typed straight into the file, or given in the environment, keeps working.
|
||||
func TestUnsealLeavesPlainValuesAlone(t *testing.T) {
|
||||
for _, plain := range []string{"hunter2", "", "gvmenc", "gvmenc1"} {
|
||||
got, err := unseal(plain)
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", plain, err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Errorf("%q came back as %q", plain, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Something that says it is sealed and is not must be an error, never an empty
|
||||
// password — that would reach vCenter and look like the wrong one.
|
||||
func TestBrokenSealIsAnError(t *testing.T) {
|
||||
good, _ := seal("hunter2")
|
||||
|
||||
for _, c := range []struct{ value, note string }{
|
||||
{sealTag + "not base64 at all!!", "not base64"},
|
||||
{sealTag, "nothing after the tag"},
|
||||
{sealTag + "c2hvcnQ=", "too short to hold a salt"},
|
||||
{good[:len(good)-4] + "AAAA", "altered"},
|
||||
} {
|
||||
got, err := unseal(c.value)
|
||||
if err == nil {
|
||||
t.Errorf("%s: opened to %q instead of failing", c.note, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("%s: gave back %q as well as an error", c.note, got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the message says what to do about it.
|
||||
if _, err := unseal(good[:len(good)-4] + "AAAA"); err == nil ||
|
||||
!strings.Contains(err.Error(), "enter it again") {
|
||||
t.Errorf("the message does not say what to do: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The vCenter's own accessor names itself in the error, so a file with three
|
||||
// servers says which one is the trouble.
|
||||
func TestVCenterPasswordNamesItself(t *testing.T) {
|
||||
word, _ := seal("hunter2")
|
||||
v := VCenter{Name: "v308", Password: word}
|
||||
if got, err := v.password(); err != nil || got != "hunter2" {
|
||||
t.Errorf("password() gave %q, %v", got, err)
|
||||
}
|
||||
|
||||
broken := VCenter{Name: "v308", Password: sealTag + "rubbish"}
|
||||
_, err := broken.password()
|
||||
if err == nil {
|
||||
t.Fatal("a broken seal came back without an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "v308") {
|
||||
t.Errorf("the error does not name the vCenter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The file rewriting. Everything but the password itself has to survive.
|
||||
func TestSealPasswordsRewritesOnlyTheSecret(t *testing.T) {
|
||||
const before = `# my configuration
|
||||
default = v308
|
||||
|
||||
vcenter.v308.url = https://v308.example/
|
||||
vcenter.v308.user = administrator@v308
|
||||
vcenter.v308.password = hunter2 # the password
|
||||
vcenter.v308.datacenter = PPB
|
||||
|
||||
vcenter.v108.password = "with spaces"
|
||||
vcenter.v108.user = admin
|
||||
|
||||
# vcenter.old.password = leave-me-alone
|
||||
mailto = me@example.org
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".gvmrc")
|
||||
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
quiet(t)
|
||||
sealPasswords(path, before)
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(after)
|
||||
|
||||
// The secrets are gone and the rest is untouched, line for line.
|
||||
for _, gone := range []string{"= hunter2", "with spaces"} {
|
||||
if strings.Contains(got, gone) {
|
||||
t.Errorf("%q still stands in the clear:\n%s", gone, got)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{
|
||||
"# my configuration", "default = v308",
|
||||
"vcenter.v308.url = https://v308.example/",
|
||||
"vcenter.v308.user = administrator@v308",
|
||||
"vcenter.v308.datacenter = PPB",
|
||||
"# the password", // the note beside it is the user's
|
||||
"# vcenter.old.password = leave-me-alone", // a commented-out line is not a setting
|
||||
"vcenter.v108.user = admin",
|
||||
"mailto = me@example.org",
|
||||
} {
|
||||
if !strings.Contains(got, kept) {
|
||||
t.Errorf("the rewrite lost %q:\n%s", kept, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, "\n") != strings.Count(before, "\n") {
|
||||
t.Errorf("the number of lines changed:\n%s", got)
|
||||
}
|
||||
|
||||
// Both passwords open again, and to what they were.
|
||||
cfg := Config{}
|
||||
applyConfig(&cfg, parseConfig(got))
|
||||
want := map[string]string{"v308": "hunter2", "v108": "with spaces"}
|
||||
for _, v := range cfg.VCenters {
|
||||
if !sealed(v.Password) {
|
||||
t.Errorf("%s was not sealed", v.Name)
|
||||
continue
|
||||
}
|
||||
if secret, err := v.password(); err != nil || secret != want[v.Name] {
|
||||
t.Errorf("%s opens to %q, %v — want %q", v.Name, secret, err, want[v.Name])
|
||||
}
|
||||
}
|
||||
|
||||
// And a second pass changes nothing at all.
|
||||
sealPasswords(path, got)
|
||||
again, _ := os.ReadFile(path)
|
||||
if string(again) != got {
|
||||
t.Errorf("sealing twice changed the file the second time:\n%s", string(again))
|
||||
}
|
||||
|
||||
// The file it writes is still readable by nobody else.
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the rewritten file is mode %04o", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSettingReplacesOrAppends(t *testing.T) {
|
||||
const before = `# top
|
||||
vcenter.v308.user = admin
|
||||
vcenter.v308.password = old
|
||||
mailto = me@example.org
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".gvmrc")
|
||||
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeSetting(path, "vcenter.v308.password", "new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readFile(t, path)
|
||||
if !strings.Contains(got, "vcenter.v308.password = new") {
|
||||
t.Errorf("the setting was not replaced:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "= old") {
|
||||
t.Errorf("the old value is still there:\n%s", got)
|
||||
}
|
||||
for _, kept := range []string{"# top", "vcenter.v308.user = admin", "mailto = me@example.org"} {
|
||||
if !strings.Contains(got, kept) {
|
||||
t.Errorf("writing lost %q:\n%s", kept, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A setting that is not there yet is added rather than lost.
|
||||
if err := writeSetting(path, "vcenter.v108.password", "brandnew"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, path); !strings.Contains(got, "vcenter.v108.password = brandnew") {
|
||||
t.Errorf("a new setting was not added:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// What `gvm config` says about a password, without saying the password.
|
||||
func TestPasswordState(t *testing.T) {
|
||||
word, _ := seal("hunter2")
|
||||
|
||||
for _, c := range []struct {
|
||||
v VCenter
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{VCenter{Name: "a", Password: word}, "password sealed", "a sealed one"},
|
||||
{VCenter{Name: "b", Password: "hunter2"}, "in the clear", "one still in the clear"},
|
||||
{VCenter{Name: "c", Password: sealTag + "rubbish"}, "does not open", "one that will not open"},
|
||||
} {
|
||||
got := stripEscapes(passwordState(c.v))
|
||||
if !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q does not say %q", c.note, got, c.want)
|
||||
}
|
||||
if strings.Contains(got, "hunter2") {
|
||||
t.Errorf("%s: the password itself is in the output: %q", c.note, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The claim of this whole file: what goes on the wire is the *opened* password,
|
||||
// never the sealed word out of ~/.gvmrc.
|
||||
//
|
||||
// Checked at loginURL rather than against a server, because govmomi's simulator
|
||||
// accepts any non-empty password by default — a login that succeeds there proves
|
||||
// nothing at all about which password was sent.
|
||||
func TestLoginURLCarriesTheOpenedPassword(t *testing.T) {
|
||||
const secret = "the-real-one"
|
||||
word, err := seal(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(word, secret) {
|
||||
t.Fatal("the sealed word contains the password, so this would prove nothing")
|
||||
}
|
||||
|
||||
v := VCenter{
|
||||
Name: "v308", URL: "https://v308.example/",
|
||||
User: "administrator@v308", Password: word,
|
||||
}
|
||||
u, err := loginURL(v)
|
||||
if err != nil {
|
||||
t.Fatalf("loginURL: %v", err)
|
||||
}
|
||||
|
||||
got, ok := u.User.Password()
|
||||
if !ok {
|
||||
t.Fatal("the url carries no password at all")
|
||||
}
|
||||
if got != secret {
|
||||
t.Errorf("the url carries %q, want the opened password", got)
|
||||
}
|
||||
if got == word {
|
||||
t.Error("the sealed word itself was put in the url")
|
||||
}
|
||||
if u.User.Username() != "administrator@v308" {
|
||||
t.Errorf("the user is %q", u.User.Username())
|
||||
}
|
||||
if u.String() == "" || !strings.HasSuffix(u.Path, "/sdk") {
|
||||
t.Errorf("the endpoint is %q", u.Path)
|
||||
}
|
||||
|
||||
// A password still in the clear goes through untouched, so a file nobody has
|
||||
// let gvm rewrite yet keeps working.
|
||||
plain := v
|
||||
plain.Password = "still-plain"
|
||||
u, err = loginURL(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := u.User.Password(); got != "still-plain" {
|
||||
t.Errorf("a plain password came through as %q", got)
|
||||
}
|
||||
|
||||
// And one that will not open never gets as far as a url.
|
||||
broken := v
|
||||
broken.Password = sealTag + "rubbish"
|
||||
if u, err := loginURL(broken); err == nil {
|
||||
got, _ := u.User.Password()
|
||||
t.Errorf("a broken seal produced a url carrying %q", got)
|
||||
} else if !strings.Contains(err.Error(), "v308") {
|
||||
t.Errorf("the error does not name the vCenter: %v", err)
|
||||
}
|
||||
}
|
||||
+1032
-16
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
// snap.go — snapshots: listing, taking and removing them.
|
||||
// snap.go — snapshots from the command line: listing, taking, reverting to and
|
||||
// removing them. The operations themselves are in snapshot_ops.go; what is here
|
||||
// is the connecting, the confirming and the reporting around them.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
@@ -22,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
|
||||
}
|
||||
@@ -32,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
|
||||
}
|
||||
|
||||
@@ -59,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
|
||||
}
|
||||
|
||||
@@ -83,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
|
||||
@@ -102,24 +118,115 @@ func snapRemove(vc VCenter, vmname, snapname string, yes bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirm(SF("remove snapshot %s of %s on %s?", snapname, vm.Name(), vc.Name), yes) {
|
||||
return nil
|
||||
}
|
||||
|
||||
consolidate := true
|
||||
task, err := vm.RemoveSnapshot(s.ctx, snapname, false, &consolidate)
|
||||
entry, err := findSnap(s, vm.Reference(), snapname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot start the removal of %s: %w", vc.Name, snapname, err)
|
||||
}
|
||||
P("waiting for vCenter, consolidating disks can take a while ...")
|
||||
if err := task.Wait(s.ctx); err != nil {
|
||||
return fmt.Errorf("%s: removing %s failed: %w", vc.Name, snapname, err)
|
||||
return err
|
||||
}
|
||||
|
||||
PO("snapshot " + snapname + " removed")
|
||||
ok, err := confirmDestructive(vc, SF("remove snapshot %s of %s", entry.name, vm.Name()),
|
||||
[][2]string{{"machine", vm.Name()}, {"snapshot", entry.name}, {"taken", entry.created}},
|
||||
"this rollback point is gone; the machine keeps running as it is", yes)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
P("waiting for vCenter, consolidating disks can take a while ...")
|
||||
if err := removeSnapshot(s, entry.ref, SF("%s of %s", entry.name, vm.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
PO("snapshot " + entry.name + " removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// snapRevert puts a machine back to one of its snapshots. The most destructive
|
||||
// thing gvm does: unlike a removed snapshot, what is thrown away here — every
|
||||
// write since the snapshot was taken — has nowhere to come back from.
|
||||
func snapRevert(vc VCenter, vmname, snapname string, yes bool) error {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.close()
|
||||
|
||||
vm, err := s.vm(vmname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := findSnap(s, vm.Reference(), snapname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := vm.PowerState(s.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot read the power state of %s: %w", vc.Name, vm.Name(), err)
|
||||
}
|
||||
|
||||
ok, err := confirmDestructive(vc, SF("revert %s to %s", vm.Name(), entry.name),
|
||||
[][2]string{
|
||||
{"machine", vm.Name()},
|
||||
{"machine state", string(state)},
|
||||
{"snapshot", entry.name},
|
||||
{"taken", entry.created},
|
||||
},
|
||||
"everything written since then is lost, and cannot be recovered — "+
|
||||
"gvm asks for the machine not to be started again afterwards", yes)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
P("waiting for vCenter ...")
|
||||
if err := revertToSnapshot(s, entry.ref, SF("%s of %s", entry.name, vm.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
now := "state unknown"
|
||||
if state, err := vm.PowerState(s.ctx); err == nil {
|
||||
now = string(state)
|
||||
}
|
||||
PO(SF("%s reverted to %s — now %s", vm.Name(), entry.name, now))
|
||||
return nil
|
||||
}
|
||||
|
||||
// findSnap resolves a snapshot name to the one snapshot it means. A name in
|
||||
// vSphere is not unique, so an ambiguous one is refused with the candidates and
|
||||
// their dates listed — picking the first would be picking a rollback point on the
|
||||
// operator's behalf, which is not gvm's to do.
|
||||
func findSnap(s *session, vmref types.ManagedObjectReference, name string) (snapEntry, error) {
|
||||
all, err := snapshotsOf(s, vmref)
|
||||
if err != nil {
|
||||
return snapEntry{}, err
|
||||
}
|
||||
|
||||
var hits []snapEntry
|
||||
for _, e := range all {
|
||||
if e.name == name {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
switch len(hits) {
|
||||
case 1:
|
||||
return hits[0], nil
|
||||
case 0:
|
||||
if len(all) == 0 {
|
||||
return snapEntry{}, errf("no snapshots at all")
|
||||
}
|
||||
names := make([]string, len(all))
|
||||
for i, e := range all {
|
||||
names[i] = SF("%s (%s)", e.name, e.created)
|
||||
}
|
||||
return snapEntry{}, errf("no snapshot called %q — there is %s",
|
||||
name, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
when := make([]string, len(hits))
|
||||
for i, e := range hits {
|
||||
when[i] = e.created
|
||||
}
|
||||
return snapEntry{}, errf("%q is the name of %d snapshots (%s) — "+
|
||||
"rename them, or use the interactive list, which picks by reference",
|
||||
name, len(hits), strings.Join(when, ", "))
|
||||
}
|
||||
|
||||
// snapRemoveAll removes every snapshot a machine has.
|
||||
func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
|
||||
s, err := connect(vc)
|
||||
@@ -133,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
|
||||
}
|
||||
@@ -141,53 +248,77 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
|
||||
P("no snapshots for", vm.Name())
|
||||
return nil
|
||||
}
|
||||
printSnapshots(tree, "")
|
||||
if !confirm(SF("remove ALL snapshots of %s on %s?", vm.Name(), vc.Name), yes) {
|
||||
return nil
|
||||
printSnapshots(tree)
|
||||
|
||||
ok, err := confirmDestructive(vc, SF("remove ALL snapshots of %s", vm.Name()),
|
||||
[][2]string{{"machine", vm.Name()}},
|
||||
"every rollback point of this machine is gone; "+
|
||||
"the machine keeps running as it is", yes)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
consolidate := true
|
||||
task, err := vm.RemoveAllSnapshot(s.ctx, &consolidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot start the removal: %w", vc.Name, err)
|
||||
}
|
||||
P("waiting for vCenter, consolidating disks can take a while ...")
|
||||
if err := task.Wait(s.ctx); err != nil {
|
||||
return fmt.Errorf("%s: removing all snapshots of %s failed: %w", vc.Name, vm.Name(), err)
|
||||
if err := removeAllSnapshots(s, vm.Reference(), vm.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PO("all snapshots of " + vm.Name() + " removed")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// confirm asks before something is destroyed. -y answers it in advance, which
|
||||
// is what a cron job needs; without a terminal and without -y the answer is no,
|
||||
// so an unattended run cannot delete anything by accident.
|
||||
func confirm(question string, yes bool) bool {
|
||||
if Yesno(question, false, yes) {
|
||||
return true
|
||||
// confirm asks before something is destroyed, and separates the two ways of not
|
||||
// getting a yes.
|
||||
//
|
||||
// Saying no is an answer: nothing happens and the command succeeds at doing
|
||||
// nothing. Not being able to ask is not an answer, and it is reported as an
|
||||
// error — otherwise `gvm power --off db01` in a script would print "nothing
|
||||
// done", exit 0, and leave the script believing the machine was stopped. -y is
|
||||
// how that script says it means it.
|
||||
func confirm(question string, yes bool) (bool, error) {
|
||||
if yes {
|
||||
return true, nil
|
||||
}
|
||||
if err := haveTerminal(); err != nil {
|
||||
return false, errf("refusing to go ahead without a confirmation: "+
|
||||
"there is no terminal to ask on (%v) — pass -y if this is meant to run unattended", err)
|
||||
}
|
||||
if Yesno(question, false, false) {
|
||||
return true, nil
|
||||
}
|
||||
P("nothing done")
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// confirmDestructive is the command line's version of the interactive
|
||||
// confirmation page: what is about to happen, to which machine, on which server,
|
||||
// and what it costs — then the question, which defaults to no.
|
||||
//
|
||||
// It does not ask for the machine's name to be typed, as the interactive list
|
||||
// does. There the machine is whatever the cursor happens to be on; here it was
|
||||
// spelled out on the command line a moment ago, and asking for it twice would
|
||||
// teach the habit of retyping without reading. -y skips the question entirely,
|
||||
// which is what a cron job needs and what an operator has to type deliberately.
|
||||
func confirmDestructive(vc VCenter, headline string, facts [][2]string, consequence string, yes bool) (bool, error) {
|
||||
P()
|
||||
PF("%s %s\n", Crb("⚠ "), Cwb(headline))
|
||||
P()
|
||||
all := append([][2]string{{"vCenter", vc.Name + " " + vc.URL}, {"datacenter", vc.Datacenter}}, facts...)
|
||||
for _, f := range all {
|
||||
PF(" %-14s %s\n", f[0], f[1])
|
||||
}
|
||||
P()
|
||||
for _, l := range wrap(consequence, 72) {
|
||||
PF(" %s\n", Cr(l))
|
||||
}
|
||||
P()
|
||||
return confirm("continue?", yes)
|
||||
}
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// snapold.go — the snapshots nobody has come back for.
|
||||
//
|
||||
// This is the one recurring job in a vSphere estate that nothing in vCenter
|
||||
// does for you: somebody takes a snapshot before an upgrade, the upgrade goes
|
||||
// well, and the snapshot stays. Six weeks later its delta disk is bigger than
|
||||
// the machine and the datastore is the thing that pages you.
|
||||
//
|
||||
// So the report reads every vCenter at once and prints one line per snapshot
|
||||
// older than the age asked for, oldest first, with what it costs — and with -m
|
||||
// it goes out by mail, which is the form it is actually useful in: this is a
|
||||
// cron job, not something anyone remembers to run.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/vmware/govmomi/units"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
var snapOldColumns = []printColumn{
|
||||
{header: "MACHINE", width: 24},
|
||||
{header: "VC", width: 4},
|
||||
{header: "SNAPSHOT", width: 22},
|
||||
{header: "AGE", width: 6, right: true},
|
||||
{header: "TAKEN", width: 16},
|
||||
{header: "SIZE", width: 9, right: true},
|
||||
}
|
||||
|
||||
// oldSnap is one snapshot in the report, with the machine it belongs to.
|
||||
type oldSnap struct {
|
||||
row vmRow
|
||||
snap snapEntry
|
||||
bytes int64 // what it owns on the datastore, 0 when that could not be read
|
||||
}
|
||||
|
||||
// snapOldReport prints the report and, when asked, mails it.
|
||||
func snapOldReport(cfg Config, targets []VCenter, days int, mail bool) error {
|
||||
if mail {
|
||||
// Asked before the sweep, not after it: finding out that the mail cannot
|
||||
// be sent is of no use once the report has scrolled past.
|
||||
if err := cfg.mailReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
for _, why := range found.failed {
|
||||
PE(why) // said before the report, where it will not be scrolled past
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Which machines have snapshots at all decides what the sizes are asked
|
||||
// for, so it is worked out before anything else is read.
|
||||
var carrying []vmRow
|
||||
for _, r := range found.rows {
|
||||
if len(r.snaps) > 0 {
|
||||
carrying = append(carrying, r)
|
||||
}
|
||||
}
|
||||
sizes := snapshotSizes(carrying)
|
||||
|
||||
var old []oldSnap
|
||||
for _, r := range carrying {
|
||||
for _, e := range r.snaps {
|
||||
if e.days() < days {
|
||||
continue
|
||||
}
|
||||
old = append(old, oldSnap{row: r, snap: e, bytes: sizes[snapKey(r, e)]})
|
||||
}
|
||||
}
|
||||
// Oldest first: that is the order the work is done in, and the first line of
|
||||
// a mail is the one that gets read.
|
||||
sort.SliceStable(old, func(i, j int) bool {
|
||||
return old[i].snap.when.Before(old[j].snap.when)
|
||||
})
|
||||
|
||||
body := snapOldTable(old, days, len(found.rows))
|
||||
if !mail || len(old) == 0 {
|
||||
return nil
|
||||
}
|
||||
return sendmail(cfg, SF("old snapshots (%s)", vcNames(targets)), "<pre>"+body+"</pre>")
|
||||
}
|
||||
|
||||
// snapOldTable prints the report and returns the same report as text.
|
||||
//
|
||||
// Both come out of one set of cells: the screen gets them painted and fitted,
|
||||
// the mail gets them plain. A report that was formatted twice would eventually
|
||||
// say two different things, and the mail is the copy nobody checks.
|
||||
func snapOldTable(old []oldSnap, days, machines int) string {
|
||||
if len(old) == 0 {
|
||||
line := SF("no snapshot on any of the %d machines is %s old", machines, plural(days, "day"))
|
||||
// Only where somebody is reading. This report runs weekly out of cron,
|
||||
// and cron mails whatever is printed: a weekly "nothing to clean up"
|
||||
// teaches everyone to filter the report away.
|
||||
if !color.NoColor {
|
||||
P(Cgb(line))
|
||||
}
|
||||
return line + "\n"
|
||||
}
|
||||
|
||||
cells := make([][]cell, 0, len(old))
|
||||
total := int64(0)
|
||||
for _, o := range old {
|
||||
total += o.bytes
|
||||
cells = append(cells, []cell{
|
||||
{o.row.name, colName},
|
||||
{o.row.vc.Name, colWhere},
|
||||
{o.snap.name, colChosen},
|
||||
{SF("%dd", o.snap.days()), ageColor(o.snap.days())},
|
||||
{o.snap.created, colAddress},
|
||||
sizeCell(o.bytes),
|
||||
})
|
||||
}
|
||||
cols := append([]printColumn(nil), snapOldColumns...)
|
||||
widen(cols, cells)
|
||||
|
||||
head := SF("%d snapshots older than %s, on %s:", len(old), plural(days, "day"),
|
||||
plural(countMachines(old), "machine"))
|
||||
tail := SF("%s in %s, on %s", units.ByteSize(total),
|
||||
plural(len(old), "snapshot"), plural(countMachines(old), "machine"))
|
||||
|
||||
P(Cwb(head))
|
||||
P()
|
||||
printRow(cols, "", nil)
|
||||
for _, c := range cells {
|
||||
printRow(cols, "", c)
|
||||
}
|
||||
P()
|
||||
P(Cob(tail))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(head + "\n\n")
|
||||
sb.WriteString(plainRow(cols, nil) + "\n")
|
||||
for _, c := range cells {
|
||||
sb.WriteString(plainRow(cols, c) + "\n")
|
||||
}
|
||||
sb.WriteString("\n" + tail + "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// countMachines counts the machines rather than the snapshots: three snapshots
|
||||
// of one machine is one machine's worth of work.
|
||||
func countMachines(old []oldSnap) int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range old {
|
||||
seen[o.row.id()] = true
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// sizeCell is what the snapshot owns, or a dash where the file layout could not
|
||||
// be read. Nought bytes and "not known" are different answers and a report that
|
||||
// prints 0 B for the second invites somebody to remove the wrong snapshot.
|
||||
func sizeCell(b int64) cell {
|
||||
if b <= 0 {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{units.ByteSize(b).String(), colSize}
|
||||
}
|
||||
|
||||
// ageColor takes the table's thresholds: yellow once a snapshot has stopped
|
||||
// being this week's, red once it has stopped being this month's.
|
||||
func ageColor(days int) string {
|
||||
switch {
|
||||
case days >= snapOldDays:
|
||||
return colFull
|
||||
case days >= snapStaleDays:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// snapKey names one snapshot of one machine across every server: two vCenters
|
||||
// hand out the same references, and a machine may hold two snapshots of one
|
||||
// name.
|
||||
func snapKey(r vmRow, e snapEntry) string { return r.id() + "/" + e.ref.Value }
|
||||
|
||||
// snapshotSizes is what each snapshot owns on the datastore.
|
||||
//
|
||||
// The file layout is asked for only for the machines that have snapshots, and
|
||||
// for all of them at once per server: it lists every file of every machine and
|
||||
// is far too much to carry through the ordinary sweep.
|
||||
func snapshotSizes(rows []vmRow) map[string]int64 {
|
||||
bySession := map[*session][]types.ManagedObjectReference{}
|
||||
rowOf := map[string]vmRow{}
|
||||
for _, r := range rows {
|
||||
if r.sess == nil {
|
||||
continue
|
||||
}
|
||||
bySession[r.sess] = append(bySession[r.sess], r.ref)
|
||||
rowOf[r.sess.vc.Name+"/"+r.ref.Value] = r
|
||||
}
|
||||
|
||||
out := map[string]int64{}
|
||||
for s, refs := range bySession {
|
||||
var vms []mo.VirtualMachine
|
||||
if err := s.objects(refs, []string{"layoutEx"}, &vms); err != nil {
|
||||
continue // the report is worth having without the sizes
|
||||
}
|
||||
for _, vm := range vms {
|
||||
r, ok := rowOf[s.vc.Name+"/"+vm.Reference().Value]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for ref, size := range snapshotBytes(vm.LayoutEx) {
|
||||
out[r.id()+"/"+ref.Value] = size
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotBytes is the size of each snapshot in one machine's file layout.
|
||||
//
|
||||
// A snapshot owns its state file — the .vmsn, with the memory in it — and the
|
||||
// *last* link of each of its disk chains. The links in front of that one are
|
||||
// the disks its ancestors froze, and the delta the machine is writing to right
|
||||
// now belongs to no snapshot at all: it is in the machine's own chain, not in
|
||||
// any snapshot's. Counting only the last link is therefore both the whole of
|
||||
// what removing this snapshot would give back and free of double counting,
|
||||
// which summing whole chains is not.
|
||||
func snapshotBytes(layout *types.VirtualMachineFileLayoutEx) map[types.ManagedObjectReference]int64 {
|
||||
if layout == nil {
|
||||
return nil
|
||||
}
|
||||
size := make(map[int32]int64, len(layout.File))
|
||||
for _, f := range layout.File {
|
||||
size[f.Key] = f.Size
|
||||
}
|
||||
|
||||
out := make(map[types.ManagedObjectReference]int64, len(layout.Snapshot))
|
||||
for _, sl := range layout.Snapshot {
|
||||
total := size[sl.DataKey]
|
||||
if sl.MemoryKey >= 0 && sl.MemoryKey != sl.DataKey {
|
||||
total += size[sl.MemoryKey]
|
||||
}
|
||||
for _, d := range sl.Disk {
|
||||
if len(d.Chain) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, key := range d.Chain[len(d.Chain)-1].FileKey {
|
||||
total += size[key]
|
||||
}
|
||||
}
|
||||
out[sl.Key] = total
|
||||
}
|
||||
return out
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// snapRef is a snapshot's reference, as the file layout keys its entries by.
|
||||
func snapRef(v string) types.ManagedObjectReference {
|
||||
return types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: v}
|
||||
}
|
||||
|
||||
// The size of a snapshot is what removing it would give back: its own state
|
||||
// file and the last link of each of its disk chains. The links in front of that
|
||||
// belong to its ancestors, and counting whole chains — which is the obvious
|
||||
// thing to do — reports the same delta once per descendant.
|
||||
func TestSnapshotBytesCountsEachDeltaOnce(t *testing.T) {
|
||||
// Two snapshots in a line. base froze file 10; after-patch froze file 11.
|
||||
// Each has a state file of its own (1 and 2), and the machine is writing to
|
||||
// file 12, which belongs to neither.
|
||||
layout := &types.VirtualMachineFileLayoutEx{
|
||||
File: []types.VirtualMachineFileLayoutExFileInfo{
|
||||
{Key: 1, Type: "snapshotData", Size: 100},
|
||||
{Key: 2, Type: "snapshotData", Size: 200},
|
||||
{Key: 10, Type: "diskExtent", Size: 1000},
|
||||
{Key: 11, Type: "diskExtent", Size: 2000},
|
||||
{Key: 12, Type: "diskExtent", Size: 4000}, // the running delta
|
||||
},
|
||||
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
|
||||
{
|
||||
Key: snapRef("snapshot-1"), DataKey: 1, MemoryKey: -1,
|
||||
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
|
||||
{FileKey: []int32{10}},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
Key: snapRef("snapshot-2"), DataKey: 2, MemoryKey: -1,
|
||||
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
|
||||
{FileKey: []int32{10}}, {FileKey: []int32{11}},
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := snapshotBytes(layout)
|
||||
if got[snapRef("snapshot-1")] != 1100 {
|
||||
t.Errorf("the first snapshot is %d bytes, want 1100", got[snapRef("snapshot-1")])
|
||||
}
|
||||
if got[snapRef("snapshot-2")] != 2200 {
|
||||
t.Errorf("the second snapshot is %d bytes, want 2200 — its parent's delta was counted again",
|
||||
got[snapRef("snapshot-2")])
|
||||
}
|
||||
}
|
||||
|
||||
// A separate memory file is part of the snapshot; a memoryKey of -1 means there
|
||||
// is not one, and the key that says so must not be looked up as a file.
|
||||
func TestSnapshotBytesTakesTheMemoryFile(t *testing.T) {
|
||||
layout := &types.VirtualMachineFileLayoutEx{
|
||||
File: []types.VirtualMachineFileLayoutExFileInfo{
|
||||
{Key: 1, Size: 100}, {Key: 3, Size: 8000},
|
||||
},
|
||||
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
|
||||
{Key: snapRef("s"), DataKey: 1, MemoryKey: 3},
|
||||
},
|
||||
}
|
||||
if got := snapshotBytes(layout)[snapRef("s")]; got != 8100 {
|
||||
t.Errorf("with a memory file the snapshot is %d bytes, want 8100", got)
|
||||
}
|
||||
|
||||
layout.Snapshot[0].MemoryKey = -1
|
||||
if got := snapshotBytes(layout)[snapRef("s")]; got != 100 {
|
||||
t.Errorf("without one it is %d bytes, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotBytesOfNothing(t *testing.T) {
|
||||
if got := snapshotBytes(nil); got != nil {
|
||||
t.Errorf("a machine with no file layout reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nought bytes and "the layout could not be read" are different answers, and
|
||||
// the second must not look like a snapshot that costs nothing.
|
||||
func TestSizeCellSaysWhenItDoesNotKnow(t *testing.T) {
|
||||
if got := sizeCell(0); got.text != "-" || got.col != colOff {
|
||||
t.Errorf("an unknown size is shown as %q", got.text)
|
||||
}
|
||||
if got := sizeCell(1 << 30); !strings.Contains(got.text, "GB") {
|
||||
t.Errorf("a gigabyte is shown as %q", got.text)
|
||||
}
|
||||
}
|
||||
|
||||
// The report's ages take the table's colours, so a red count in the list and a
|
||||
// red line in the mail mean the same thing.
|
||||
func TestAgeColorMatchesTheTable(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want string
|
||||
}{
|
||||
{0, colSize}, {snapStaleDays - 1, colSize},
|
||||
{snapStaleDays, colBusy}, {snapOldDays - 1, colBusy},
|
||||
{snapOldDays, colFull}, {365, colFull},
|
||||
} {
|
||||
if got := ageColor(c.days); got != c.want {
|
||||
t.Errorf("a snapshot of %d days is coloured wrongly", c.days)
|
||||
}
|
||||
}
|
||||
|
||||
// And the same thresholds the column uses.
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.snaps = []snapEntry{aged("s", snapOldDays+1)}
|
||||
if r.snapColor() != colFull {
|
||||
t.Error("the column and the report disagree about an old snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// A report with nothing in it says so in the same breath as saying what it
|
||||
// looked for: "no old snapshots" without the age is not an answer.
|
||||
//
|
||||
// And it says it only where somebody is reading. The report runs weekly out of
|
||||
// cron, and cron mails whatever is printed: a weekly "nothing to clean up"
|
||||
// teaches everybody to filter the report away, and then the week it has
|
||||
// something to say is filtered away with it.
|
||||
func TestAnEmptyReportSaysWhatItLookedForOnlyOnATerminal(t *testing.T) {
|
||||
var body string
|
||||
out := captureStdout(t, func() { body = snapOldTable(nil, 30, 212) })
|
||||
if out != "" {
|
||||
t.Errorf("an empty report printed %q into a pipe", out)
|
||||
}
|
||||
if !strings.Contains(body, "212") || !strings.Contains(body, "30 days") {
|
||||
t.Errorf("the empty report reads %q", body)
|
||||
}
|
||||
|
||||
onATerminal(t)
|
||||
out = captureStdout(t, func() { snapOldTable(nil, 30, 212) })
|
||||
if !strings.Contains(stripEscapes(out), "no snapshot") {
|
||||
t.Errorf("on a terminal the empty report printed %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// onATerminal makes the colour library — which is also gvm's answer to "is
|
||||
// anybody reading this" — say yes for the length of one test.
|
||||
func onATerminal(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := color.NoColor
|
||||
color.NoColor = false
|
||||
t.Cleanup(func() { color.NoColor = saved })
|
||||
}
|
||||
|
||||
// The report counts machines, not snapshots: three snapshots of one machine is
|
||||
// one machine's worth of work.
|
||||
func TestCountMachines(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
other := testRow("db01", true, "10.0.0.6")
|
||||
other.ref = types.ManagedObjectReference{Value: "vm-43"}
|
||||
|
||||
old := []oldSnap{
|
||||
{row: r, snap: aged("a", 40)},
|
||||
{row: r, snap: aged("b", 50)},
|
||||
{row: other, snap: aged("c", 60)},
|
||||
}
|
||||
if got := countMachines(old); got != 2 {
|
||||
t.Errorf("three snapshots on two machines counted as %d machines", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mail carries the same table as the screen, and carries no escape
|
||||
// sequences: a mail client shows those as four stray characters per colour.
|
||||
func TestTheMailedReportHasNoColours(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
old := []oldSnap{{row: r, snap: aged("before-patch", 63), bytes: 3 << 30}}
|
||||
|
||||
var body string
|
||||
out := captureStdout(t, func() { body = snapOldTable(old, 30, 1) })
|
||||
|
||||
for _, want := range []string{"web01", "before-patch", "63d"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the mail leaves out %q:\n%s", want, body)
|
||||
}
|
||||
if !strings.Contains(stripEscapes(out), want) {
|
||||
t.Errorf("the screen leaves out %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune(body, 0x1b) {
|
||||
t.Errorf("the mail carries escape sequences:\n%q", body)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
// snapshot_ops.go — reverting to a snapshot and removing one, addressed exactly.
|
||||
//
|
||||
// Everything here takes a snapshot's ManagedObjectReference, never its name.
|
||||
// govmomi's RevertToSnapshot and RemoveSnapshot look a name up first, and a name
|
||||
// in vSphere is not unique: two snapshots of the same machine may share one, and
|
||||
// gvm lets the operator type whatever they like. Resolving by name would then
|
||||
// either refuse (govmomi's own behaviour, "resolves to 2 snapshots") or, worse,
|
||||
// invite the habit of trusting a name to identify a rollback point. The picker in
|
||||
// the list carries the reference of the row that was highlighted, and that exact
|
||||
// snapshot is what gets reverted to or removed.
|
||||
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. 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 // when it was taken, as it is shown
|
||||
when time.Time // and as it is compared: an age is not a string
|
||||
depth int
|
||||
prefix string // the branch drawn in front of the name
|
||||
current bool // the state the machine is running from
|
||||
}
|
||||
|
||||
// age is how long ago the snapshot was taken. A snapshot whose date did not
|
||||
// come back has no age rather than an age of nothing: zero would read as
|
||||
// "taken just now", which is the opposite of what an absent date means.
|
||||
func (e snapEntry) age() (time.Duration, bool) {
|
||||
if e.when.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
return time.Since(e.when), true
|
||||
}
|
||||
|
||||
// days is the age in whole days, for the reports that count in them.
|
||||
func (e snapEntry) days() int {
|
||||
d, ok := e.age()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(d.Hours() / 24)
|
||||
}
|
||||
|
||||
// line is the entry as it is shown: its branch, its name, when it was taken, and
|
||||
// a mark when it is the one the machine is running from.
|
||||
func (e snapEntry) line() string {
|
||||
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)
|
||||
|
||||
var mvm mo.VirtualMachine
|
||||
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)
|
||||
}
|
||||
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 info.CurrentSnapshot != nil {
|
||||
current = *info.CurrentSnapshot
|
||||
}
|
||||
return flattenSnapshots(info.RootSnapshotList, current)
|
||||
}
|
||||
|
||||
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
|
||||
// written since is gone; that is what the operation is for, and why every caller
|
||||
// confirms it in full first.
|
||||
//
|
||||
// suppressPowerOn keeps the machine off afterwards. Without it, reverting to a
|
||||
// snapshot that was taken while the machine was running starts it again — so the
|
||||
// default here is to leave it off and let the operator start it deliberately,
|
||||
// rather than have a machine come back up with a rewound disk while they are
|
||||
// still reading the message.
|
||||
func revertToSnapshot(s *session, snap types.ManagedObjectReference, what string) error {
|
||||
res, err := methods.RevertToSnapshot_Task(s.ctx, s.client.Client, revertRequest(snap))
|
||||
if err != nil {
|
||||
return errf("%s: cannot start the revert of %s: %w", s.vc.Name, what, err)
|
||||
}
|
||||
return waitTask(s.ctx, object.NewTask(s.client.Client, res.Returnval), snapshotWait,
|
||||
SF("revert of %s", what))
|
||||
}
|
||||
|
||||
// revertRequest is the request itself, separate so that the one thing that
|
||||
// cannot be observed afterwards — that gvm asked for the machine *not* to be
|
||||
// started again — can be checked directly.
|
||||
func revertRequest(snap types.ManagedObjectReference) *types.RevertToSnapshot_Task {
|
||||
return &types.RevertToSnapshot_Task{
|
||||
This: snap,
|
||||
SuppressPowerOn: types.NewBool(true),
|
||||
}
|
||||
}
|
||||
|
||||
// removeSnapshot removes the exact snapshot given and consolidates the disks
|
||||
// afterwards. Its children are kept and re-parented, which is vSphere's own
|
||||
// behaviour and the one that loses the least: removing a snapshot is about
|
||||
// reclaiming the space of one rollback point, not of everything below it.
|
||||
func removeSnapshot(s *session, snap types.ManagedObjectReference, what string) error {
|
||||
consolidate := true
|
||||
res, err := methods.RemoveSnapshot_Task(s.ctx, s.client.Client, &types.RemoveSnapshot_Task{
|
||||
This: snap,
|
||||
RemoveChildren: false,
|
||||
Consolidate: &consolidate,
|
||||
})
|
||||
if err != nil {
|
||||
return errf("%s: cannot start the removal of %s: %w", s.vc.Name, what, err)
|
||||
}
|
||||
return waitTask(s.ctx, object.NewTask(s.client.Client, res.Returnval), consolidateWait,
|
||||
SF("removal of %s", what))
|
||||
}
|
||||
|
||||
// removeAllSnapshots removes every snapshot the machine has.
|
||||
func removeAllSnapshots(s *session, ref types.ManagedObjectReference, what string) error {
|
||||
vm := object.NewVirtualMachine(s.client.Client, ref)
|
||||
consolidate := true
|
||||
|
||||
task, err := vm.RemoveAllSnapshot(s.ctx, &consolidate)
|
||||
if err != nil {
|
||||
return errf("%s: cannot start the removal: %w", s.vc.Name, err)
|
||||
}
|
||||
return waitTask(s.ctx, task, consolidateWait, SF("removal of all snapshots of %s", what))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// tasks.go — what a machine is in the middle of.
|
||||
//
|
||||
// A machine being cloned, migrated or consolidated looks in the table exactly
|
||||
// like one that is idle, and that is the one moment when the table is wrong
|
||||
// about the most important thing on the line: why the machine is slow, why its
|
||||
// disk is growing, why it must be left alone. vCenter keeps the answer on the
|
||||
// machine itself, in recentTask, so the sweep picks it up along the way.
|
||||
//
|
||||
// "Recent" is vCenter's word, not gvm's: a task stays on that list for minutes
|
||||
// after it has finished. Only the ones that are still going on are shown — a
|
||||
// finished task is history, and the event log is where history belongs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// runningTask is one thing vCenter is doing to a machine right now.
|
||||
type runningTask struct {
|
||||
what string // the operation, in one word
|
||||
queued bool // accepted but not started yet
|
||||
progress int32
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// taskVerbs are the operations worth naming in the eight characters the column
|
||||
// has left once a percentage is beside them — "pwr off" for the same reason the
|
||||
// sort legend says pwr. The key is
|
||||
// vSphere's descriptionId, which is the same word on every vCenter — the
|
||||
// task's own Description is localised, so a German vCenter would put German
|
||||
// into an English table.
|
||||
//
|
||||
// Anything not listed keeps its method name, which is still the truth and still
|
||||
// tells an operator to leave the machine alone.
|
||||
var taskVerbs = map[string]string{
|
||||
"createSnapshot": "snapshot",
|
||||
"removeSnapshot": "rm snap",
|
||||
"removeAllSnapshots": "rm snaps",
|
||||
"revertToSnapshot": "revert",
|
||||
"consolidateDisks": "consolid",
|
||||
"promoteDisks": "consolid",
|
||||
"clone": "clone",
|
||||
"relocate": "migrate",
|
||||
"migrate": "migrate",
|
||||
"reconfigure": "reconfig",
|
||||
"powerOn": "pwr on",
|
||||
"powerOff": "pwr off",
|
||||
"suspend": "suspend",
|
||||
"reset": "reset",
|
||||
"shutdownGuest": "shutdown",
|
||||
"rebootGuest": "reboot",
|
||||
"destroy": "delete",
|
||||
"customize": "custom",
|
||||
"createDisk": "disk",
|
||||
"extendDisk": "disk",
|
||||
"upgradeTools": "tools",
|
||||
"upgradeVirtualHardware": "hardware",
|
||||
}
|
||||
|
||||
// taskVerb is the operation in one word. A descriptionId reads
|
||||
// "VirtualMachine.createSnapshot"; the kind in front of the dot is already the
|
||||
// row the task is on, so only what follows it says anything.
|
||||
func taskVerb(descriptionID string) string {
|
||||
method := descriptionID
|
||||
if i := strings.LastIndexByte(method, '.'); i >= 0 {
|
||||
method = method[i+1:]
|
||||
}
|
||||
if v, ok := taskVerbs[method]; ok {
|
||||
return v
|
||||
}
|
||||
if method == "" {
|
||||
return "busy"
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// cell is the task as the table shows it: what it is and how far it has got.
|
||||
// A percentage is only shown once there is one — vCenter reports 0 both for
|
||||
// "just started" and for "no idea", and a task sitting at 0 % looks stuck when
|
||||
// it is merely young.
|
||||
func (t runningTask) cell() string {
|
||||
if t.queued {
|
||||
return t.what + " q"
|
||||
}
|
||||
if t.progress > 0 {
|
||||
return SF("%s %d%%", t.what, t.progress)
|
||||
}
|
||||
return t.what
|
||||
}
|
||||
|
||||
// line is the task on the machine's sheet, where there is room to say when it
|
||||
// started and that queued means nothing has happened yet.
|
||||
func (t runningTask) line() string {
|
||||
parts := []string{t.what}
|
||||
switch {
|
||||
case t.queued:
|
||||
parts = append(parts, "queued, not started")
|
||||
case t.progress > 0:
|
||||
parts = append(parts, SF("%d %%", t.progress))
|
||||
}
|
||||
if !t.since.IsZero() {
|
||||
parts = append(parts, "since "+t.since.Local().Format("15:04:05"))
|
||||
}
|
||||
return join(parts)
|
||||
}
|
||||
|
||||
// runningTasks maps the machines of one sweep to what is being done to them.
|
||||
// The task references come off the machines themselves and are read in one
|
||||
// call for the whole inventory, so this costs one round trip per vCenter no
|
||||
// matter how much is going on.
|
||||
//
|
||||
// A machine with two tasks at once keeps the one that has started; of two
|
||||
// running ones, the first. There is one column, and "something is going on" is
|
||||
// what it has to say.
|
||||
func runningTasks(s *session, vms []mo.VirtualMachine) map[types.ManagedObjectReference]runningTask {
|
||||
var refs []types.ManagedObjectReference
|
||||
for _, vm := range vms {
|
||||
refs = append(refs, vm.RecentTask...)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks, err := s.tasks(refs)
|
||||
if err != nil {
|
||||
return nil // the table is worth having without it
|
||||
}
|
||||
byRef := make(map[types.ManagedObjectReference]runningTask, len(tasks))
|
||||
for _, t := range tasks {
|
||||
rt, ok := taskOf(t.Info)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
byRef[t.Reference()] = rt
|
||||
}
|
||||
|
||||
out := make(map[types.ManagedObjectReference]runningTask, len(vms))
|
||||
for _, vm := range vms {
|
||||
for _, ref := range vm.RecentTask {
|
||||
rt, ok := byRef[ref]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cur, seen := out[vm.Reference()]; seen && (!cur.queued || rt.queued) {
|
||||
continue
|
||||
}
|
||||
out[vm.Reference()] = rt
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// taskOf is one task, when it is still going on.
|
||||
func taskOf(info types.TaskInfo) (runningTask, bool) {
|
||||
switch info.State {
|
||||
case types.TaskInfoStateRunning:
|
||||
since := info.QueueTime
|
||||
if info.StartTime != nil {
|
||||
since = *info.StartTime
|
||||
}
|
||||
return runningTask{what: taskVerb(info.DescriptionId), progress: info.Progress, since: since}, true
|
||||
case types.TaskInfoStateQueued:
|
||||
return runningTask{what: taskVerb(info.DescriptionId), queued: true, since: info.QueueTime}, true
|
||||
}
|
||||
return runningTask{}, false
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The operation is named from vSphere's descriptionId and not from the task's
|
||||
// own Description, which vCenter localises: a German vCenter would otherwise
|
||||
// put German words in an English table.
|
||||
func TestTaskVerb(t *testing.T) {
|
||||
for _, c := range []struct{ id, want string }{
|
||||
{"VirtualMachine.createSnapshot", "snapshot"},
|
||||
{"VirtualMachine.removeAllSnapshots", "rm snaps"},
|
||||
{"VirtualMachine.relocate", "migrate"},
|
||||
{"VirtualMachine.reconfigure", "reconfig"},
|
||||
{"Datacenter.somethingNobodyHasHeardOf", "somethingNobodyHasHeardOf"},
|
||||
{"noDotAtAll", "noDotAtAll"},
|
||||
{"", "busy"},
|
||||
} {
|
||||
if got := taskVerb(c.id); got != c.want {
|
||||
t.Errorf("taskVerb(%q) = %q, want %q", c.id, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every verb fits the column, or the cell it is put in would be truncated with
|
||||
// the percentage — the part that says whether anything is happening — cut off.
|
||||
func TestEveryTaskVerbFitsItsColumn(t *testing.T) {
|
||||
room := taskColumn.width - len(" 100%")
|
||||
for id, verb := range taskVerbs {
|
||||
if len(verb) > room {
|
||||
t.Errorf("%s is called %q, which is %d characters of the %d there are",
|
||||
id, verb, len(verb), room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A task at nought per cent has not reported any progress, which is not the
|
||||
// same as having made none: printing 0 % makes a task that has just started
|
||||
// look stuck.
|
||||
func TestTaskCell(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
task runningTask
|
||||
want string
|
||||
}{
|
||||
{runningTask{what: "clone", progress: 40}, "clone 40%"},
|
||||
{runningTask{what: "clone"}, "clone"},
|
||||
{runningTask{what: "clone", queued: true}, "clone q"},
|
||||
{runningTask{what: "clone", queued: true, progress: 10}, "clone q"},
|
||||
} {
|
||||
if got := c.task.cell(); got != c.want {
|
||||
t.Errorf("cell() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLineSpellsQueuedOut(t *testing.T) {
|
||||
since := time.Date(2026, 9, 8, 11, 42, 0, 0, time.Local)
|
||||
got := runningTask{what: "consolid", queued: true, since: since}.line()
|
||||
if got != "consolid · queued, not started · since 11:42:00" {
|
||||
t.Errorf("the sheet line reads %q", got)
|
||||
}
|
||||
got = runningTask{what: "clone", progress: 40, since: since}.line()
|
||||
if got != "clone · 40 % · since 11:42:00" {
|
||||
t.Errorf("the sheet line reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only what is still going on is a task. A task that has finished stays on
|
||||
// vCenter's recentTask list for minutes afterwards, and a table that showed it
|
||||
// would report a snapshot being taken long after it was taken.
|
||||
func TestOnlyRunningTasksCount(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
state types.TaskInfoState
|
||||
want bool
|
||||
}{
|
||||
{types.TaskInfoStateRunning, true},
|
||||
{types.TaskInfoStateQueued, true},
|
||||
{types.TaskInfoStateSuccess, false},
|
||||
{types.TaskInfoStateError, false},
|
||||
} {
|
||||
_, ok := taskOf(types.TaskInfo{State: c.state, DescriptionId: "VirtualMachine.clone"})
|
||||
if ok != c.want {
|
||||
t.Errorf("a %s task counts = %v, want %v", c.state, ok, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A running task is timed from when it started, a queued one from when it was
|
||||
// accepted — there is nothing else to time it from.
|
||||
func TestTaskTakesItsTimeFromTheRightEnd(t *testing.T) {
|
||||
queued := time.Date(2026, 9, 8, 11, 0, 0, 0, time.UTC)
|
||||
started := time.Date(2026, 9, 8, 11, 5, 0, 0, time.UTC)
|
||||
|
||||
got, _ := taskOf(types.TaskInfo{
|
||||
State: types.TaskInfoStateRunning, QueueTime: queued, StartTime: &started,
|
||||
DescriptionId: "VirtualMachine.clone",
|
||||
})
|
||||
if !got.since.Equal(started) {
|
||||
t.Errorf("a running task is timed from %v", got.since)
|
||||
}
|
||||
|
||||
got, _ = taskOf(types.TaskInfo{
|
||||
State: types.TaskInfoStateQueued, QueueTime: queued,
|
||||
DescriptionId: "VirtualMachine.clone",
|
||||
})
|
||||
if !got.since.Equal(queued) {
|
||||
t.Errorf("a queued task is timed from %v", got.since)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,26 @@
|
||||
// ======================================================================================= go toolbox (mwx'2026)
|
||||
//
|
||||
// What gvm actually uses. The toolbox this came from carries a good deal more —
|
||||
// base62 encoders, a mysql row reader, viper and gjson wrappers, an ip-range
|
||||
// access check — and none of it was ever called here. Carried along, it was three
|
||||
// dependencies and a page of code that nothing exercised and no test covered,
|
||||
// including one access check that had never been wired up and would have panicked
|
||||
// on the first malformed entry in its list. It is gone; the toolbox it belongs to
|
||||
// still has it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/eknkc/basex"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var tbversion = "0.6.0"
|
||||
|
||||
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
|
||||
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// Updating oneself lives in selfupdate.go now: releases of a Gitea instance,
|
||||
// fetched over TLS and probed before they replace the running binary. What used
|
||||
// to be here downloaded a binary over plain http and applied it unverified.
|
||||
|
||||
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
|
||||
addrs, err := net.InterfaceAddrs() // once, not once per configured network
|
||||
if err != nil {
|
||||
PE("Error getting addresses")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
match := 0
|
||||
for _, validnet := range NETS {
|
||||
_, ipNet, err := net.ParseCIDR(validnet)
|
||||
if err != nil { // a typo in the list is a typo, not a nil dereference
|
||||
PE("bad network " + validnet)
|
||||
continue
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
if ipNet.Contains(ipnet.IP) {
|
||||
match++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match == 0 {
|
||||
PE("access violation, permission denied")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Enc(str string) string { // ----------------------------------------------------------------- encode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return enc.Encode([]byte(Rndstr(2) + str))
|
||||
}
|
||||
|
||||
func Dec(str string) string { // ----------------------------------------------------------------- decode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := enc.Decode(str)
|
||||
if err != nil || len(b) < 2 { // the 2 bytes are the Rndstr(2) prefix Enc puts in front
|
||||
return ""
|
||||
}
|
||||
return string(b[2:])
|
||||
}
|
||||
|
||||
func Db64(txt string) string { // ------------------------------------------------------------- string to base64
|
||||
d, _ := base64.StdEncoding.DecodeString(txt)
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func Rndstr(n int) string { // ------------------------------------------------------- random string with length
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(LR))))
|
||||
b[i] = LR[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func Input(msg string, def string) string { // -------------------------------- AlecAivazis/survey: input string
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Input{Message: msg, Default: def}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
|
||||
|
||||
if overwrite {
|
||||
@@ -149,14 +49,16 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
|
||||
}
|
||||
}
|
||||
|
||||
func Getid(n int) string { // --------------------------------------------------------- get base62 random string
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ret := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
ret[i] = letters[num.Int64()]
|
||||
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return string(ret)
|
||||
return tmp
|
||||
}
|
||||
|
||||
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
|
||||
@@ -169,70 +71,6 @@ func GETid(n int) string { // --------------------------------------------------
|
||||
return string(ret)
|
||||
}
|
||||
|
||||
func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table
|
||||
defer rows.Close()
|
||||
columns, _ := rows.Columns()
|
||||
count := len(columns)
|
||||
tableData := make([]map[string]interface{}, 0)
|
||||
values := make([]interface{}, count)
|
||||
valuePtrs := make([]interface{}, count)
|
||||
for rows.Next() {
|
||||
for i := 0; i < count; i++ {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
rows.Scan(valuePtrs...)
|
||||
entry := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
var v interface{}
|
||||
val := values[i]
|
||||
b, ok := val.([]byte)
|
||||
if ok {
|
||||
v = string(b)
|
||||
} else {
|
||||
v = val
|
||||
}
|
||||
entry[col] = v
|
||||
}
|
||||
tableData = append(tableData, entry)
|
||||
}
|
||||
return (tableData)
|
||||
}
|
||||
|
||||
func Checkip(network string, ip string) bool { // ---------- check if ip is in range (cidr address or single ip)
|
||||
if net.ParseIP(ip) == nil {
|
||||
return false
|
||||
}
|
||||
_, subnet, err := net.ParseCIDR(network)
|
||||
if err == nil {
|
||||
if subnet.Contains(net.ParseIP(ip)) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if network == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Isflagpassed(name string) bool { // -------------------------------------------------- check if flag is set
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func Body(r *http.Response) string { // ------------------------------------------------------------ http body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
return string(body)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Atoi(s string) int { // ------------------------------------------------------------------------------ atoi
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
@@ -245,14 +83,6 @@ func Itoa(i int) string { // ---------------------------------------------------
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func GJA(j string, k string) []string { // -------------------------------------------------- convert gjson array
|
||||
var ret []string
|
||||
for _, c := range gjson.Get(j, k).Array() {
|
||||
ret = append(ret, c.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ print simple
|
||||
|
||||
func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Stdout, a...) }
|
||||
@@ -276,51 +106,8 @@ func PO(msg ...string) (n int, err error) {
|
||||
// errf is fmt.Errorf under a shorter name, to go with the P/PF/SF family.
|
||||
func errf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// ------------------------------------------------------------------------------------------ regular expression
|
||||
|
||||
func ReplaceFirst(re *regexp.Regexp, str, replace string) string {
|
||||
loc := re.FindStringIndex(str)
|
||||
if loc == nil {
|
||||
return str
|
||||
}
|
||||
return str[:loc[0]] + replace + str[loc[1]:]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- string functions
|
||||
|
||||
func Shortstr(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= length {
|
||||
return s
|
||||
}
|
||||
return string(runes[:length-2]) + ".."
|
||||
}
|
||||
|
||||
func SR(str string, n int) string { return strings.Repeat(str, n) }
|
||||
|
||||
func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array
|
||||
// A new slice, not slice[:0]: filtering in place writes through the caller's
|
||||
// backing array, so the caller's own variable is left holding shifted,
|
||||
// duplicated entries — and it looks like a pure function at the call site.
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != target {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- system functions
|
||||
|
||||
func prgname() string { // program name
|
||||
exepath, err := os.Executable()
|
||||
if err != nil {
|
||||
PE(SF("Error getting executable path: %s", err))
|
||||
return ""
|
||||
}
|
||||
exename := filepath.Base(exepath)
|
||||
return exename
|
||||
}
|
||||
|
||||
// ========================================================================================================= END
|
||||
|
||||
@@ -82,8 +82,11 @@ const (
|
||||
keyTab
|
||||
keyShiftTab
|
||||
keyEnter
|
||||
keyCtrlA
|
||||
keyCtrlO
|
||||
keyCtrlR
|
||||
keyCtrlS
|
||||
keyCtrlW
|
||||
keyCtrlC
|
||||
keyEsc
|
||||
)
|
||||
@@ -133,10 +136,19 @@ func (kr *keyReader) next() key {
|
||||
switch b {
|
||||
case 0x03:
|
||||
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':
|
||||
@@ -180,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':
|
||||
@@ -199,22 +239,21 @@ func (kr *keyReader) readEscape() key {
|
||||
return key{special: keyEnd}
|
||||
case 'Z':
|
||||
return key{special: keyShiftTab}
|
||||
case '1', '3', '4', '5', '6':
|
||||
kr.readByte() // consume trailing '~'
|
||||
switch b3 {
|
||||
case '1':
|
||||
case '~':
|
||||
switch string(params) {
|
||||
case "1", "7":
|
||||
return key{special: keyHome}
|
||||
case '3':
|
||||
case "3":
|
||||
return key{special: keyDelete}
|
||||
case '4':
|
||||
case "4", "8":
|
||||
return key{special: keyEnd}
|
||||
case '5':
|
||||
case "5":
|
||||
return key{special: keyPgUp}
|
||||
case '6':
|
||||
case "6":
|
||||
return key{special: keyPgDn}
|
||||
}
|
||||
}
|
||||
return key{special: keyEsc}
|
||||
return key{special: keyNone}
|
||||
}
|
||||
|
||||
func utf8SeqLen(b byte) int {
|
||||
|
||||
+84
-4
@@ -15,8 +15,10 @@ import (
|
||||
"github.com/vmware/govmomi"
|
||||
"github.com/vmware/govmomi/find"
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/property"
|
||||
"github.com/vmware/govmomi/view"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
|
||||
@@ -30,6 +32,12 @@ type session struct {
|
||||
ctx context.Context
|
||||
client *govmomi.Client
|
||||
cancel context.CancelFunc
|
||||
|
||||
// The names of the alarm definitions this server has triggered, filled in by
|
||||
// the sweep when anything is actually alarming (browse.go). It belongs to
|
||||
// the connection rather than to a machine: one alarm stands against many
|
||||
// machines, and its name is worth reading once per server, not once per row.
|
||||
alarms map[types.ManagedObjectReference]string
|
||||
}
|
||||
|
||||
// connect logs in to one server. The caller closes what comes back — a session
|
||||
@@ -38,12 +46,11 @@ type session struct {
|
||||
func connect(vc VCenter) (*session, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
u, err := url.Parse(vc.sdkURL())
|
||||
u, err := loginURL(vc)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
|
||||
return nil, err
|
||||
}
|
||||
u.User = url.UserPassword(vc.User, vc.Password)
|
||||
|
||||
dial, dialCancel := context.WithTimeout(ctx, dialTimeout)
|
||||
defer dialCancel()
|
||||
@@ -56,6 +63,24 @@ func connect(vc VCenter) (*session, error) {
|
||||
return &session{vc: vc, ctx: ctx, client: client, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
// loginURL is the endpoint with the credentials in it — the one place where a
|
||||
// password is opened and handed over. Its own function so that what goes on the
|
||||
// wire can be checked without a server: whether the password that leaves here is
|
||||
// the opened one, and not the sealed word out of the file, is the whole claim of
|
||||
// seal.go.
|
||||
func loginURL(vc VCenter) (*url.URL, error) {
|
||||
u, err := url.Parse(vc.sdkURL())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
|
||||
}
|
||||
secret, err := vc.password()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.User = url.UserPassword(vc.User, secret)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// close logs out and drops the context. Logging out is best effort: there is
|
||||
// nothing useful to do about a failure while shutting down.
|
||||
func (s *session) close() {
|
||||
@@ -105,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vms and hosts are the two inventory sweeps gvm makes.
|
||||
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
|
||||
// for everything of that kind in the whole inventory.
|
||||
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
|
||||
var out []mo.VirtualMachine
|
||||
return out, s.retrieve("VirtualMachine", props, &out)
|
||||
@@ -115,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
|
||||
var out []mo.HostSystem
|
||||
return out, s.retrieve("HostSystem", props, &out)
|
||||
}
|
||||
|
||||
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
|
||||
var out []mo.Datastore
|
||||
return out, s.retrieve("Datastore", props, &out)
|
||||
}
|
||||
|
||||
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
|
||||
// vSphere client puts in the URLs of the objects it shows, which is the one
|
||||
// thing gvm cannot work out from the configuration alone (see vsphereURL).
|
||||
func (s *session) instanceUUID() string {
|
||||
if s.client == nil {
|
||||
return ""
|
||||
}
|
||||
return s.client.ServiceContent.About.InstanceUuid
|
||||
}
|
||||
|
||||
// objects fills dst with the named properties of exactly the objects given,
|
||||
// rather than of everything of a kind. Alarm definitions and tasks are not in
|
||||
// the inventory container view — they hang off their managers — so the only way
|
||||
// to read them is by reference, and by all of them in one call: one round trip
|
||||
// for a screenful, not one per line.
|
||||
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := property.DefaultCollector(s.client.Client).
|
||||
Retrieve(s.ctx, refs, props, dst); err != nil {
|
||||
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tasks reads what those task references are doing. A task that has finished is
|
||||
// still on a machine's recentTask list for a while afterwards, so the caller
|
||||
// decides what counts as going on; this only reports.
|
||||
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
|
||||
var out []mo.Task
|
||||
return out, s.objects(refs, []string{"info"}, &out)
|
||||
}
|
||||
|
||||
// alarmNames resolves alarm definitions to the names a person gave them. A
|
||||
// triggered alarm carries only the reference of its definition, and "alarm-3 is
|
||||
// red" is not something anyone can act on.
|
||||
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
|
||||
var alarms []mo.Alarm
|
||||
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
|
||||
return nil // the names are a courtesy; the references still say which
|
||||
}
|
||||
out := make(map[types.ManagedObjectReference]string, len(alarms))
|
||||
for _, a := range alarms {
|
||||
out[a.Reference()] = a.Info.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.11
|
||||
1.1.7
|
||||
|
||||
@@ -1,110 +1,131 @@
|
||||
// vm.go — the virtual machine listing.
|
||||
// vm.go — the printed machine listing.
|
||||
//
|
||||
// The same columns, cells and colours as the interactive list, from the same
|
||||
// table: `gvm vm -l` and `gvm vm` used to disagree about the very same machine,
|
||||
// the printed one showing a bare 32 where the other showed 32.0MB, and lacking
|
||||
// the power state, the host and both load figures entirely. There is one
|
||||
// definition of what a row of machines looks like (browse.go) and both views
|
||||
// render it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// lsvm lists the virtual machines of every server it is given, sorted by name
|
||||
// across all of them. One unreachable vCenter is a warning and not the end: the
|
||||
// old version called log.Fatal inside the loop, which threw away everything the
|
||||
// servers before it had already answered.
|
||||
//
|
||||
// The servers are asked at the same time. Three logins one after another are
|
||||
// three round trips to three machines, and there is nothing to be gained by
|
||||
// waiting for each in turn.
|
||||
func lsvm(targets []VCenter, match string) error {
|
||||
re, err := regexp.Compile("(?i)" + match)
|
||||
// lsOptions is what one printed listing was asked for. A struct rather than six
|
||||
// arguments in a row: three of them are booleans, and a call site reading
|
||||
// (rows, "", false, true, false) says nothing about which is which.
|
||||
type lsOptions struct {
|
||||
match string // regexp on the machine's name
|
||||
orderBy string // one of sortOrders, by letter or by name
|
||||
reverse bool
|
||||
issues bool // only the machines with something wrong with them
|
||||
json bool // as a document instead of a table
|
||||
}
|
||||
|
||||
// lsvm prints the machines of every server it is given. One unreachable vCenter
|
||||
// is a line of complaint, not the end of the listing.
|
||||
func lsvm(targets []VCenter, opt lsOptions) error {
|
||||
re, err := regexp.Compile("(?i)" + opt.match)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad pattern %q: %w", match, err)
|
||||
return errf("bad pattern %q: %w", opt.match, err)
|
||||
}
|
||||
by, err := findOrder(opt.orderBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
xfmt = "%-32s %-8s %4s %-15s %3d %8d\n"
|
||||
xfmts = "%-32s %-8s %4s %-15s %3s %8s\n"
|
||||
)
|
||||
PF(xfmts, "VM NAME", "ID", "VM", "IP", "CPU", "MEMORY")
|
||||
PF(xfmts, SR("=", 32), SR("=", 8), SR("=", 4), SR("=", 15), SR("=", 3), SR("=", 8))
|
||||
|
||||
rows := make([][]string, len(targets))
|
||||
errs := make([]error, len(targets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, vc := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, vc VCenter) {
|
||||
defer wg.Done()
|
||||
rows[i], errs[i] = listOne(vc, re, xfmt)
|
||||
}(i, vc)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var all []string
|
||||
failed := 0
|
||||
for i := range targets {
|
||||
if errs[i] != nil {
|
||||
PE(errs[i].Error())
|
||||
failed++
|
||||
continue
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
// In a table the failures are said before it, where they will not be
|
||||
// scrolled past. In a document they belong *in* it: a line of prose in the
|
||||
// middle of the JSON would break whatever is reading it, and a script that
|
||||
// cannot tell "no machines" from "the server did not answer" is a script
|
||||
// that reports an empty cluster.
|
||||
if !opt.json {
|
||||
for _, why := range found.failed {
|
||||
PE(why)
|
||||
}
|
||||
all = append(all, rows[i]...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(all)
|
||||
for _, line := range all {
|
||||
PN(line)
|
||||
rows := make([]vmRow, 0, len(found.rows))
|
||||
for _, r := range found.rows {
|
||||
if re.MatchString(r.name) {
|
||||
rows = append(rows, r)
|
||||
}
|
||||
}
|
||||
if opt.issues {
|
||||
rows = withIssues(rows)
|
||||
}
|
||||
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
|
||||
|
||||
// Nothing at all came back: that is a failure of the command, not a listing
|
||||
// that happens to be empty, and the exit status should say so.
|
||||
if failed == len(targets) {
|
||||
return fmt.Errorf("no vCenter answered")
|
||||
if opt.json {
|
||||
return printJSON(found, rows)
|
||||
}
|
||||
// Nothing to report, and nobody watching: say nothing at all. This is the
|
||||
// listing that belongs in cron, and cron mails whatever a command prints —
|
||||
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
|
||||
// one morning it did not arrive would mean nothing either. On a terminal
|
||||
// somebody is waiting for an answer, so there it is said.
|
||||
if opt.issues && len(rows) == 0 {
|
||||
if !color.NoColor {
|
||||
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
printList(rows, listColumns(rows, opt.issues))
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOne collects the formatted lines of a single server.
|
||||
func listOne(vc VCenter, re *regexp.Regexp, xfmt string) ([]string, error) {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.close()
|
||||
// printList writes the table. On a terminal it is fitted to the width, the same
|
||||
// as the interactive one; into a pipe every column is written and every column is
|
||||
// made as wide as the longest thing in it.
|
||||
//
|
||||
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
|
||||
// machine names off at twenty-two characters — silently, with an ellipsis, into
|
||||
// output whose whole purpose is to be read by something else.
|
||||
func printList(rows []vmRow, table []browseColumn) {
|
||||
cols := fitColumnsOf(table, printWidth(table))
|
||||
|
||||
vms, err := s.vms("summary", "guest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
pcs := make([]printColumn, len(cols))
|
||||
for i, c := range cols {
|
||||
pcs[i] = printColumn{header: c.header, width: c.width}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, vm := range vms {
|
||||
name := vm.Summary.Config.Name
|
||||
if name == "" || !re.MatchString(name) {
|
||||
continue
|
||||
body := make([][]cell, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cells := make([]cell, len(cols))
|
||||
for i, c := range cols {
|
||||
col := ""
|
||||
if c.color != nil {
|
||||
col = c.color(r)
|
||||
}
|
||||
cells[i] = cell{text: c.cell(r), col: col}
|
||||
}
|
||||
|
||||
// Both of these are optional in vSphere and absent for a machine that
|
||||
// is being created or deleted while the listing runs — reading them
|
||||
// unguarded is a nil dereference at exactly the wrong moment.
|
||||
ip := "-"
|
||||
if vm.Guest != nil && vm.Guest.IpAddress != "" {
|
||||
ip = vm.Guest.IpAddress
|
||||
}
|
||||
id := "-"
|
||||
if vm.Summary.Vm != nil {
|
||||
id = vm.Summary.Vm.Value
|
||||
}
|
||||
|
||||
out = append(out, SF(xfmt,
|
||||
Shortstr(name, 32),
|
||||
id,
|
||||
vc.Name,
|
||||
ip,
|
||||
vm.Summary.Config.NumCpu,
|
||||
vm.Summary.Config.MemorySizeMB))
|
||||
body = append(body, cells)
|
||||
}
|
||||
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
|
||||
widen(pcs, body)
|
||||
}
|
||||
|
||||
printRow(pcs, "", nil)
|
||||
for _, cells := range body {
|
||||
printRow(pcs, "", cells)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// printWidth is what the table is laid out for. color.NoColor is the answer to
|
||||
// "is this a terminal" that the colours already go by, so the two cannot disagree
|
||||
// about where the output is headed.
|
||||
func printWidth(table []browseColumn) int {
|
||||
if color.NoColor {
|
||||
return tableWidth(table)
|
||||
}
|
||||
cols, _ := termSize()
|
||||
return max(cols, 20)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user