Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba15b7897 | ||
|
|
4a5477bde7 | ||
|
|
137a799399 |
@@ -121,6 +121,8 @@ help, as does anything gvm does not recognise:
|
||||
memory in use, uptime, storage, guest filesystems, network
|
||||
adapters, snapshots, uuid and moref
|
||||
↑ ↓ in there scroll the sheet, esc/enter back to the list
|
||||
e h y w in the sheet: recent events, ssh to the guest, copy what
|
||||
ssh would connect to, open it in the vSphere client
|
||||
|
||||
The sheet is one line per thing worth knowing, values that belong together
|
||||
joined with a middle dot and no section headings — an ordinary machine fits a
|
||||
@@ -178,8 +180,7 @@ be said — `^w`, `--issues` and `snap --old` all name it and date it.
|
||||
`^o` puts a legend on the status line and the next key picks the order, so the
|
||||
list stays on screen while it rearranges itself:
|
||||
|
||||
sort: n·name p·pwr c·cpu% m·mem% #·snaps o·old w·why
|
||||
s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
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
|
||||
@@ -194,21 +195,24 @@ direction the order runs. Machines that compare equal stay in name order, so
|
||||
flipping the direction on a screen full of identical figures does not reshuffle
|
||||
them.
|
||||
|
||||
Two lines, because thirteen choices do not fit across eighty columns and a
|
||||
legend that ran off the edge would hide the very choices it exists to offer.
|
||||
They are grouped where the meaning breaks: what the machine is doing and what it
|
||||
wants doing to it above, what it is made of and where it lives below. The second
|
||||
line takes the help line's row, which describes keys that do nothing while a
|
||||
menu is waiting for one.
|
||||
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:
|
||||
|
||||
* `#` is by how many snapshots the machine is carrying, most first. Nought is a
|
||||
* `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 key is not a letter because every
|
||||
letter with a mnemonic in it was taken, and a hash is what a count is written
|
||||
with anyway; `--sort snapshots` needs no shell quoting.
|
||||
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
|
||||
@@ -283,8 +287,12 @@ sheet:
|
||||
|
||||
^a the action menu (see below)
|
||||
^s take a snapshot: a name, then a confirmation
|
||||
e recent events
|
||||
h ssh to the guest, as root
|
||||
y copy that name or address to the clipboard
|
||||
w open in the vSphere client
|
||||
|
||||
Pressing either in the table says so rather than doing nothing visible.
|
||||
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
|
||||
|
||||
`^s` takes two steps.
|
||||
|
||||
@@ -299,57 +307,78 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
|
||||
name is on the status line, and a sheet that is open jumps to its snapshot
|
||||
section so the new one is there to see.
|
||||
|
||||
### The action menu
|
||||
### The sheet's four letters
|
||||
|
||||
In a machine's sheet, `^a` opens the menu for it. Above the choices it repeats
|
||||
the few lines of the sheet the choice depends on — state, guest, hostname,
|
||||
address — taken from the sheet itself, so the two cannot word the same fact
|
||||
differently. On a terminal too short for both, those lines go one at a time,
|
||||
least useful first: the state stays longest because every choice depends on it,
|
||||
then the address and the hostname, because two of the choices are about them.
|
||||
Everything that changes a
|
||||
machine lives there and nowhere else — the list is arrowed through and its filter
|
||||
swallows every ordinary letter, so a hotkey that powered a machine off would sit
|
||||
one fumbled control key away from an outage, and the sheet has to be opened first
|
||||
anyway.
|
||||
|
||||
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)
|
||||
────────────────────────────────────────────────────────────
|
||||
e recent events h ssh to the guest
|
||||
y copy the address w open in the vSphere client
|
||||
|
||||
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
|
||||
always needs the shift key. What cannot be done right now is greyed out with the
|
||||
reason next to it — "no VMware Tools", "already running", "no address" — rather
|
||||
than left out, and picking it anyway spells the reason out instead of running it.
|
||||
|
||||
The group under the rule changes nothing, on the machine or on the vCenter. It
|
||||
is here rather than on four more control keys of its own because this is where
|
||||
one already looks for "what can I do with this machine", and because the sheet's
|
||||
help line is not the place to learn them:
|
||||
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, by its own hostname where it reports one and by
|
||||
its address otherwise. The terminal goes back to what it was for as long as
|
||||
that lasts. `ssh = ssh -l root %h` in `~/.gvmrc` says how; the target is
|
||||
always one argument and never goes through a shell, because it is a name the
|
||||
guest chose for itself.
|
||||
* **`y`** copies that address to the clipboard — the terminal's own, asked for
|
||||
with an escape sequence rather than through `pbcopy`, so it works over ssh and
|
||||
lands where the person actually is.
|
||||
* **`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:
|
||||
@@ -588,6 +617,14 @@ selected row on a `darker` surface with a `violet` pointer, the filter's hits in
|
||||
`pink`, counts in `green`, questions in `yellow`, errors in `red`, headers and
|
||||
the help line in `dark`.
|
||||
|
||||
Every line at the foot of the screen that wants an answer is that one `yellow`,
|
||||
whatever kind of question it is: the sort legend, both its lines; a yes/no
|
||||
question and its hint; the label in front of a snapshot name or the `YES` of a
|
||||
confirmation. They are different kinds of question and one state — gvm is
|
||||
waiting for a key — and that state is worth learning once, in one place and one
|
||||
tone, rather than being worked out per screen. What is typed in answer stays
|
||||
`white`: it is the operator's, not part of the question.
|
||||
|
||||
Inside the table and the sheet every colour is a role, not a decoration:
|
||||
|
||||
| | |
|
||||
@@ -676,6 +713,12 @@ wrong quietly:
|
||||
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
|
||||
|
||||
+49
-55
@@ -59,15 +59,16 @@ var menuFacts = []string{"state", "guest", "hostname", "address"}
|
||||
// most worth keeping first, the same idea as the table's expendable columns.
|
||||
//
|
||||
// The state comes first because every choice below depends on it. The address
|
||||
// and the hostname come next because two of the choices are *about* them: ssh
|
||||
// and copy-the-address. The guest's operating system decides nothing here.
|
||||
// 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 terminal of
|
||||
// twenty-four rows gets out of this: the menu grew a group of its own, all four
|
||||
// facts no longer fit above it there, and none is a worse answer than three.
|
||||
// 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
|
||||
@@ -189,44 +190,9 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
separator(),
|
||||
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
|
||||
pwr('B', "reset (hard, at the hypervisor)", opReset),
|
||||
separator(),
|
||||
// The last group changes nothing, on the machine or on the vCenter: it
|
||||
// reads its history, copies its address, opens it somewhere else. It is
|
||||
// here rather than on keys of its own because the sheet's help line is
|
||||
// not the place to learn four more control keys, and because this is
|
||||
// where one already looks for "what can I do with this machine".
|
||||
{key: 'e', label: "recent events", run: func(b *browser, r vmRow) { b.showEvents(r) }},
|
||||
guestItem('h', "ssh to the guest", r, func(b *browser, r vmRow) { b.sshTo(r) }),
|
||||
guestItem('y', "copy the address to the clipboard", r,
|
||||
func(b *browser, r vmRow) { b.copyAddress(r) }),
|
||||
vsphereItem('w', "open in the vSphere client", r),
|
||||
}
|
||||
}
|
||||
|
||||
// guestItem is one of the entries that needs somewhere to connect to. A machine
|
||||
// whose guest is not talking has no address, and saying so where the entry is
|
||||
// beats a keystroke that does nothing.
|
||||
func guestItem(key rune, label string, r vmRow, run func(*browser, vmRow)) menuItem {
|
||||
if r.sshTarget() == "" {
|
||||
return menuItem{key: key, label: label, hint: "no address",
|
||||
why: SF("%s has no address or hostname — its guest is not reporting one", r.name),
|
||||
run: run}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: run}
|
||||
}
|
||||
|
||||
// vsphereItem needs the same, plus a way to open a browser: on a machine with
|
||||
// no desktop the URL is still worth having, so it is offered and printed rather
|
||||
// than left out.
|
||||
func vsphereItem(key rune, label string, r vmRow) menuItem {
|
||||
if vsphereURL(r) == "" {
|
||||
return menuItem{key: key, label: label, hint: "no connection",
|
||||
why: "the vSphere client link needs the connection this machine was read over",
|
||||
run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
|
||||
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
|
||||
// do the same for anyone who would rather read than remember.
|
||||
func (b *browser) menuKey(k key) {
|
||||
@@ -637,7 +603,7 @@ func (b *browser) renderMenu() {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
segLine(&sb, cols, seg{colDim,
|
||||
"lowercase asks the guest, uppercase the hypervisor · the last group only reads"})
|
||||
"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())
|
||||
@@ -793,13 +759,17 @@ func wrap(s string, width int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the harmless half of the menu
|
||||
// ------------------------------------------------------- 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.closeMenu()
|
||||
b.working(SF("reading the events of %s ...", r.name))
|
||||
|
||||
lines, err := eventsOf(r)
|
||||
@@ -818,41 +788,65 @@ func (b *browser) showEvents(r vmRow) {
|
||||
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) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
argv := sshCommand(b.ssh, target)
|
||||
|
||||
if err := b.runInTerminal(argv); err != nil {
|
||||
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))
|
||||
return
|
||||
}
|
||||
b.setStatus(colDim, "back from "+target)
|
||||
}
|
||||
|
||||
// copyAddress puts the address where the next paste will find it, by asking the
|
||||
// terminal rather than the operating system — see osc52.
|
||||
// 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) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
b.write(osc52(target))
|
||||
b.setStatus(colInfo, "copied "+target)
|
||||
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) {
|
||||
b.closeMenu()
|
||||
url := vsphereURL(r)
|
||||
if url == "" {
|
||||
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
|
||||
return
|
||||
}
|
||||
if err := openURL(url); err != nil {
|
||||
b.write(osc52(url))
|
||||
b.toClipboard(url) // the same two routes as `y`
|
||||
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
|
||||
return
|
||||
}
|
||||
|
||||
+3
-3
@@ -606,9 +606,9 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
|
||||
wantFacts bool
|
||||
}{
|
||||
{"30", true}, // room for both, all four facts
|
||||
{"24", true}, // room for three of them, the hostname among them
|
||||
{"23", false}, // room for two: the state and the address
|
||||
{"18", false}, // no room at all, so the choices have the screen
|
||||
{"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)
|
||||
|
||||
|
||||
@@ -39,20 +39,29 @@ const (
|
||||
// The screen's colours. The comment on each is the name mwxcol's fzf theme gives
|
||||
// the same job, so the two stay in step.
|
||||
var (
|
||||
colRow = cGrey.fg() // fg
|
||||
colRowSel = cWhite.fg() // fg+
|
||||
colSurface = cDarker.bg() // bg+
|
||||
colMatch = cPink.fg() // hl, hl+
|
||||
colPointer = cViolet.fg() // pointer
|
||||
colHeader = cDark.fg() // header, label
|
||||
colInfo = cGreen.fg() // info
|
||||
colQuery = cWhite.fg() // query
|
||||
colErr = cRed.fg() // prompt
|
||||
colWarn = cYellow.fg() // not in the theme: a question, a wait
|
||||
colTitle = attrBold + cWhite.fg() //
|
||||
colLabel = cDark.fg() // the sheet's field names
|
||||
colValue = cWhite.fg() // its values, where nothing better applies
|
||||
colDim = cDark.fg() // disabled
|
||||
colRow = cGrey.fg() // fg
|
||||
colRowSel = cWhite.fg() // fg+
|
||||
colSurface = cDarker.bg() // bg+
|
||||
colMatch = cPink.fg() // hl, hl+
|
||||
colPointer = cViolet.fg() // pointer
|
||||
colHeader = cDark.fg() // header, label
|
||||
colInfo = cGreen.fg() // info
|
||||
colQuery = cWhite.fg() // query
|
||||
colErr = cRed.fg() // prompt
|
||||
colWarn = cYellow.fg() // not in the theme: a question, a wait
|
||||
// colPrompt is every line at the foot of the screen that wants an answer:
|
||||
// the sort legend, a yes/no question, the label in front of something being
|
||||
// typed. One colour for all of them, whatever kind of question it is —
|
||||
// yellow, which is the job mwxcol's own theme gives it — so that "gvm is
|
||||
// waiting for me" is learned once, in one place and one tone, rather than
|
||||
// being a thing to work out per screen. It is the same value as colWarn and
|
||||
// a name of its own, because the two mean different things and only one of
|
||||
// them may ever change.
|
||||
colPrompt = cYellow.fg()
|
||||
colTitle = attrBold + cWhite.fg() //
|
||||
colLabel = cDark.fg() // the sheet's field names
|
||||
colValue = cWhite.fg() // its values, where nothing better applies
|
||||
colDim = cDark.fg() // disabled
|
||||
)
|
||||
|
||||
// What the columns and the sheet's values are coloured by. Every one of these is
|
||||
@@ -74,7 +83,7 @@ var (
|
||||
|
||||
const (
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
|
||||
detailHelp = "↑/↓ scroll ^a actions (events, ssh, vsphere) ^s snapshot esc/⏎ back ^c quit"
|
||||
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
|
||||
gutter = 2 // the pointer's two columns, in front of every row
|
||||
colSep = 2
|
||||
labelWidth = 12
|
||||
@@ -750,7 +759,7 @@ type browser struct {
|
||||
|
||||
// browseVMs is the command: gather, then hand the terminal over to the loop.
|
||||
//
|
||||
// ssh is the command line the action menu's `h` runs, out of the configuration:
|
||||
// ssh is the command line the sheet's `h` runs, out of the configuration:
|
||||
// the browser is handed it rather than reading it, so nothing in the interactive
|
||||
// half has to know where settings come from.
|
||||
func browseVMs(targets []VCenter, filter, ssh string) error {
|
||||
@@ -948,6 +957,8 @@ func (b *browser) detailKey(k key) {
|
||||
b.openMenu()
|
||||
case keyCtrlS:
|
||||
b.snapshot()
|
||||
case keyRune:
|
||||
b.detailRune(k.r)
|
||||
case keyUp:
|
||||
b.dscroll = max(b.dscroll-1, 0)
|
||||
case keyDown:
|
||||
@@ -963,6 +974,32 @@ func (b *browser) detailKey(k key) {
|
||||
}
|
||||
}
|
||||
|
||||
// detailRune answers the sheet's own letters: the four things that only read the
|
||||
// machine or point somewhere else at it. They are letters here rather than
|
||||
// entries at the foot of the action menu because nothing they do needs thinking
|
||||
// about first, and the sheet — unlike the list — has no filter to swallow them.
|
||||
// A letter that means nothing here is ignored, not complained about.
|
||||
func (b *browser) detailRune(r rune) {
|
||||
row := b.current()
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
switch r {
|
||||
case 'e':
|
||||
b.showEvents(*row)
|
||||
case 'h':
|
||||
if b.hasAddress(*row) {
|
||||
b.sshTo(*row)
|
||||
}
|
||||
case 'y':
|
||||
if b.hasAddress(*row) {
|
||||
b.copyAddress(*row)
|
||||
}
|
||||
case 'w':
|
||||
b.openVSphere(*row)
|
||||
}
|
||||
}
|
||||
|
||||
// refilter rebuilds the visible set. The selection stays on the machine it was
|
||||
// on where that machine is still in the list, which is what makes typing a few
|
||||
// letters and pressing enter feel like one motion.
|
||||
@@ -1223,7 +1260,7 @@ type prompt struct {
|
||||
// ask puts one question on the status line and waits for a single key. Only "y"
|
||||
// means yes — every other key, Esc and Ctrl-C included, means no.
|
||||
func (b *browser) ask(question string) bool {
|
||||
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
|
||||
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
|
||||
b.render()
|
||||
k := b.keys.next()
|
||||
b.prompt = nil
|
||||
@@ -1353,12 +1390,13 @@ func (b *browser) renderList() {
|
||||
b.write(sb.String())
|
||||
}
|
||||
|
||||
// editLine draws the input in the status area: the label stays put, the typed
|
||||
// text is the query colour, and a hint says what Enter alone would do.
|
||||
// editLine draws the input in the status area: the label stays put in the colour
|
||||
// every question at the foot of the screen has, and the typed text is the query
|
||||
// colour, because it is the answer and not part of the question.
|
||||
func (b *browser) editLine(sb *strings.Builder, cols int) {
|
||||
segLine(sb, cols,
|
||||
seg{colWarn, b.edit.label},
|
||||
seg{colQuery, string(b.edit.runes)})
|
||||
seg{colPrompt, b.edit.label},
|
||||
seg{colQuery, string(b.edit.runes)}) // the answer is the operator's, and white
|
||||
}
|
||||
|
||||
// parkCursor puts the terminal's own cursor where the typing happens and shows
|
||||
|
||||
+137
-2
@@ -251,6 +251,71 @@ func TestListViewHasNoActionKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The four that only read a machine are the sheet's own letters now, not entries
|
||||
// at the foot of the action menu. Each one has to reach its own action from
|
||||
// there, and a letter that means nothing must be dropped rather than answered.
|
||||
func TestSheetLettersReachTheirActions(t *testing.T) {
|
||||
// Said to be an ssh login, which keeps the test off the clipboard of whoever
|
||||
// is running it: with no local tool to hand, `y` uses the terminal's own
|
||||
// escape sequence and nothing else.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
// That sequence goes to the terminal, so the sheet needs one to write to.
|
||||
pipe := func(b *browser) func() string {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = w
|
||||
return func() string {
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
|
||||
// y copies. It is the one of the four that needs neither a session nor a
|
||||
// browser, so it is checked all the way through.
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune('y')
|
||||
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
|
||||
t.Errorf("y sent no clipboard sequence: %q", got)
|
||||
}
|
||||
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
|
||||
// business; here it only has to name the thing that was copied.
|
||||
for _, want := range []string{"clipboard", "web01.example"} {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("y said %q, which does not mention %q", b.status, want)
|
||||
}
|
||||
}
|
||||
|
||||
// e and w need the connection the machine was read over, and these rows have
|
||||
// none: what matters is that the letter arrived at the action, which says so.
|
||||
for _, c := range []struct {
|
||||
k rune
|
||||
want string
|
||||
}{{'e', "v308"}, {'w', "v308"}} {
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune(c.k)
|
||||
read()
|
||||
if !strings.Contains(b.status, c.want) {
|
||||
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A letter nothing is bound to is ignored — silently, because the sheet is
|
||||
// not a filter and there is nothing to correct.
|
||||
b = testBrowser("web01")
|
||||
b.setStatus("", "")
|
||||
b.detailRune('q')
|
||||
if b.status != "" {
|
||||
t.Errorf("an unbound letter said %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
@@ -1390,14 +1455,84 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
|
||||
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
// The four that only read the machine are the sheet's own letters, so the
|
||||
// sheet is where they have to be advertised.
|
||||
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
|
||||
if !strings.Contains(sheet, want) {
|
||||
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
b.menu = b.buildMenu(*b.current(), b.current().snaps)
|
||||
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
|
||||
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
|
||||
for _, want := range []string{"take a snapshot", "power off", "recent events",
|
||||
"ssh to the guest", "copy the address", "vSphere client"} {
|
||||
for _, want := range []string{"take a snapshot", "power off", "reset"} {
|
||||
if !strings.Contains(menu, want) {
|
||||
t.Errorf("the menu does not show %q:\n%s", want, menu)
|
||||
}
|
||||
}
|
||||
// And the menu is now only the things that change a machine.
|
||||
for _, gone := range []string{"recent events", "ssh to the guest",
|
||||
"copy the address", "vSphere client"} {
|
||||
if strings.Contains(menu, gone) {
|
||||
t.Errorf("the menu still holds %q:\n%s", gone, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every line at the foot of the screen that wants an answer wears one colour.
|
||||
// The sort legend is a menu, a confirmation is a yes/no question and a snapshot
|
||||
// name is something typed, and they are three different kinds of question — but
|
||||
// "gvm is waiting for me" is one thing, and it is learned once rather than
|
||||
// worked out per screen.
|
||||
func TestEveryQuestionWearsOneColour(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "12")
|
||||
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
set func()
|
||||
text string
|
||||
lines int // how many of the bottom rows the question occupies
|
||||
}{
|
||||
{"the sort legend", func() {
|
||||
// Narrower than the whole legend, so it is the two-line one: the
|
||||
// second line is the part that could quietly lose its colour.
|
||||
lines := sortLegend(60)
|
||||
b.prompt = &prompt{text: lines[0], col: colPrompt, more: lines[1]}
|
||||
b.edit = nil
|
||||
}, "sort:", 2},
|
||||
{"a yes/no question", func() {
|
||||
b.prompt = &prompt{text: "power on web01 on v308?", col: colPrompt}
|
||||
b.edit = nil
|
||||
}, "power on web01", 1},
|
||||
{"a name being typed", func() {
|
||||
b.prompt = nil
|
||||
b.edit = &editor{label: "name the snapshot of web01: "}
|
||||
}, "name the snapshot", 1},
|
||||
} {
|
||||
c.set()
|
||||
frame := renderToPipe(t, b, b.renderList)
|
||||
|
||||
if !strings.Contains(frame, colPrompt+c.text) {
|
||||
t.Errorf("%s is not in the colour every question has:\n%s",
|
||||
c.what, frame[max(len(frame)-400, 0):])
|
||||
}
|
||||
// And the second line of a two-line question is in it as well, or the
|
||||
// menu would fade out halfway down.
|
||||
if c.lines == 2 {
|
||||
second := stripEscapes(frame)
|
||||
if !strings.Contains(second, "r·reverse") {
|
||||
t.Errorf("%s lost its second line:\n%s", c.what, second)
|
||||
}
|
||||
if strings.Count(frame, colPrompt) < 2 {
|
||||
t.Errorf("%s does not carry the colour onto its second line", c.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.prompt, b.edit = nil, nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type Config struct {
|
||||
SMTPHost string // relay to hand it to
|
||||
SMTPPort string // its port (default 25)
|
||||
Telemetry string // URL `host -t` posts to; unset turns the posting off
|
||||
SSH string // the command the action menu's `h` runs; %h is the machine
|
||||
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
|
||||
@@ -587,10 +587,10 @@ func writeConfigTemplate(path string) {
|
||||
b.WriteString("# smtpport = 25\n\n")
|
||||
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
|
||||
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
|
||||
b.WriteString("# --- how the action menu's 'h' logs in to a guest ---\n")
|
||||
b.WriteString("# --- 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 plain 'ssh <machine>'.\n")
|
||||
b.WriteString("# ssh = ssh -l root %h\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())
|
||||
|
||||
@@ -4,33 +4,51 @@
|
||||
// take its address away with you, open it in the vSphere client. They are the
|
||||
// keystrokes that stop gvm being a viewer you then have to type an address out
|
||||
// of by hand — and none of them touches the vCenter at all, which is why they
|
||||
// sit in the harmless group at the foot of the action menu.
|
||||
// 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 {
|
||||
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)
|
||||
return strings.TrimSpace(g.HostName), "hostname"
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
return ip
|
||||
return ip, "address"
|
||||
}
|
||||
return ""
|
||||
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
|
||||
@@ -39,7 +57,7 @@ func (r vmRow) sshTarget() string {
|
||||
// quoting is not supported, which is a limit worth having here.
|
||||
func sshCommand(template, target string) []string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
template = "ssh %h"
|
||||
template = defaultSSH
|
||||
}
|
||||
fields := strings.Fields(template)
|
||||
|
||||
@@ -116,16 +134,74 @@ func openURL(url string) error {
|
||||
}
|
||||
|
||||
// osc52 is the escape sequence that puts text in the clipboard of the terminal
|
||||
// that is being looked at, wherever that terminal is running.
|
||||
// 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.
|
||||
//
|
||||
// Deliberately not pbcopy or xclip: gvm is run over ssh as often as not, and a
|
||||
// local pbcopy would then copy an address into the clipboard of a machine
|
||||
// nobody is sitting at. This asks the terminal itself, which is the one program
|
||||
// in the chain that knows where the person is.
|
||||
// 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.
|
||||
@@ -139,11 +215,13 @@ func (b *browser) runInTerminal(argv []string) error {
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
@@ -161,3 +239,42 @@ func (b *browser) runInTerminal(argv []string) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+159
-31
@@ -2,9 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
@@ -33,8 +39,8 @@ func TestSSHCommand(t *testing.T) {
|
||||
template string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{"ssh", "web01"}},
|
||||
{"ssh %h", []string{"ssh", "web01"}},
|
||||
{"", []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"}},
|
||||
@@ -46,6 +52,142 @@ func TestSSHCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -110,47 +252,33 @@ func TestOsc52CarriesTheTextItself(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The menu says why an entry cannot be used rather than leaving it out, and the
|
||||
// two entries that need somewhere to connect to say exactly that.
|
||||
func TestTheMenuGreysOutWhatItCannotDo(t *testing.T) {
|
||||
// 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}}
|
||||
menu := b.buildMenu(r, nil)
|
||||
|
||||
for _, key := range []rune{'h', 'y'} {
|
||||
found := false
|
||||
for _, m := range menu {
|
||||
if m.key != key {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if m.available() {
|
||||
t.Errorf("%q is offered on a machine with no address", string(key))
|
||||
}
|
||||
if m.hint == "" || m.why == "" {
|
||||
t.Errorf("%q is greyed out without saying why", string(key))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the menu has no %q entry", string(key))
|
||||
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 they are there to be used.
|
||||
r = testRow("web01", true, "10.0.0.5")
|
||||
for _, m := range b.buildMenu(r, nil) {
|
||||
if (m.key == 'h' || m.key == 'y') && !m.available() {
|
||||
t.Errorf("%q is greyed out on a machine with an address: %s", string(m.key), m.why)
|
||||
}
|
||||
// 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 harmless group must not have taken a letter the
|
||||
// dangerous half already uses.
|
||||
// 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{}
|
||||
|
||||
+5
-3
@@ -69,14 +69,16 @@ smtpport = 25
|
||||
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
|
||||
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
|
||||
|
||||
# --- ssh, for the action menu's 'h' ---
|
||||
# --- 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 plain "ssh <machine>".
|
||||
# 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 root %h
|
||||
# ssh = ssh -l someone %h
|
||||
|
||||
# --- the same settings from the environment ---
|
||||
# Every setting above has an environment spelling that wins over the file:
|
||||
|
||||
+1
-1
@@ -1170,7 +1170,7 @@ func TestSimIssuesListing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's own events, which is what the action menu's 'e' fetches.
|
||||
// The machine's own events, which is what 'e' on a machine's sheet fetches.
|
||||
func TestSimEventsOfOneMachine(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
@@ -45,10 +45,10 @@ var sortOrders = []sortOrder{
|
||||
// the machine — so a machine with none sorts where nought belongs, at the
|
||||
// bottom going down and at the top coming back up.
|
||||
//
|
||||
// The key is not a letter because every letter with a mnemonic in it is
|
||||
// taken, and a hash is what a count is written with anyway. `--sort
|
||||
// snapshots` on the command line needs no shell quoting.
|
||||
{key: '#', name: "snapshots", natural: true,
|
||||
// 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
|
||||
@@ -233,32 +233,47 @@ func (b *browser) sortLabel() string {
|
||||
return arrow + " " + b.order().name
|
||||
}
|
||||
|
||||
// sortLegend is the choices, on the two lines they are grouped into: what the
|
||||
// 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. Terse by necessity — it shares the bottom of the screen with nothing
|
||||
// but itself — and the title says what the order is anyway, so nobody who
|
||||
// misses it is lost.
|
||||
// lives.
|
||||
//
|
||||
// The second line is indented under the first one's entries rather than under
|
||||
// its label, so the two read as one list and not as a sentence continued.
|
||||
func sortLegend() []string {
|
||||
// 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: "
|
||||
|
||||
lines := []string{label}
|
||||
entries := make([]string, 0, len(sortOrders)+1)
|
||||
for _, o := range sortOrders {
|
||||
entry := string(o.key) + "·" + shortName(o.name)
|
||||
if o.legendBreak && len(lines) == 1 {
|
||||
lines = append(lines, SR(" ", len(label)))
|
||||
}
|
||||
at := len(lines) - 1
|
||||
if !strings.HasSuffix(lines[at], " ") {
|
||||
lines[at] += " "
|
||||
}
|
||||
lines[at] += entry
|
||||
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:], " "),
|
||||
}
|
||||
// Reverse is not an order of its own and goes at the end of the last line.
|
||||
lines[len(lines)-1] += " " + string(sortReverse) + "·reverse"
|
||||
return lines
|
||||
}
|
||||
|
||||
// shortName is the legend's spelling: the title has room for the whole name, one
|
||||
@@ -293,11 +308,14 @@ func shortName(name string) string {
|
||||
// choice leaves the order alone: this is the one prompt in the list that is
|
||||
// reached by accident, and doing nothing is the right answer to a stray key.
|
||||
func (b *browser) sortPrompt() {
|
||||
// A menu, not a warning: the colour is the ordinary value colour and there
|
||||
// is no yes/no hint. Where it takes two lines the second one goes in place
|
||||
// of the help line, which says nothing that applies while a menu is up.
|
||||
lines := sortLegend()
|
||||
b.prompt = &prompt{text: lines[0], col: colValue}
|
||||
// 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:], " ")
|
||||
}
|
||||
|
||||
+42
-5
@@ -221,7 +221,7 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
|
||||
|
||||
// Every order is offered, and the legend fits a terminal of eighty.
|
||||
func TestSortLegend(t *testing.T) {
|
||||
lines := sortLegend()
|
||||
lines := sortLegend(80)
|
||||
legend := strings.Join(lines, "\n")
|
||||
for _, o := range sortOrders {
|
||||
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
|
||||
@@ -247,6 +247,35 @@ func TestSortLegend(t *testing.T) {
|
||||
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 {
|
||||
@@ -314,10 +343,10 @@ func TestSortBySnapshotCount(t *testing.T) {
|
||||
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
|
||||
rows[2].snaps = []snapEntry{aged("a", 1)}
|
||||
|
||||
if got := orderOf(t, rows, '#', true); got != "three one none" {
|
||||
if got := orderOf(t, rows, 'z', true); got != "three one none" {
|
||||
t.Errorf("most snapshots first gave %q", got)
|
||||
}
|
||||
if got := orderOf(t, rows, '#', false); got != "none one three" {
|
||||
if got := orderOf(t, rows, 'z', false); got != "none one three" {
|
||||
t.Errorf("fewest first gave %q", got)
|
||||
}
|
||||
}
|
||||
@@ -356,14 +385,22 @@ func TestSortByIssues(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 = i
|
||||
b.sortBy, found = i, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%q is not one of the sort orders", string(key))
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
var names []string
|
||||
@@ -384,7 +421,7 @@ func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
|
||||
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
|
||||
rows[1].snaps = []snapEntry{aged("march", 200)}
|
||||
|
||||
if got := orderOf(t, rows, '#', true); got != "many-new one-ancient" {
|
||||
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" {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.1
|
||||
1.1.7
|
||||
|
||||
Reference in New Issue
Block a user