Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae936420e0 | ||
|
|
1aab0cf4a5 | ||
|
|
94ebe679b7 | ||
|
|
cc4daece4b | ||
|
|
5b23d6b3cd | ||
|
|
b7252d7a6d | ||
|
|
da56a5d048 | ||
|
|
2da48d9b25 |
@@ -109,6 +109,7 @@ not.
|
||||
| `new --from <template> --name <vm>` | make a new machine from a template |
|
||||
| `new ... --host <h> --datastore <ds> --on` | where it lands, and whether it starts |
|
||||
| `new ... --spec <spec> --ip <addr>` | customise the guest: hostname and address |
|
||||
| `new ... --ip auto` | have the site's `dns` tool hand an address out |
|
||||
| `new --specs` | the customisation specifications this vCenter holds |
|
||||
| `config` | also shows what a new guest would be told about the network |
|
||||
| `host [-t]` | per-host cpu, memory, machine counts; `-t` also posts them |
|
||||
@@ -264,6 +265,10 @@ It refreshes over the connections that are already open. `^r` logs in again,
|
||||
which is how a session that has died is recovered; doing that every ten seconds
|
||||
would be three logins a minute for nothing.
|
||||
|
||||
`^l` is not the only way in: making a machine from a template turns it on, since
|
||||
a clone that has just been started is exactly the screen this is for, and says
|
||||
so in the same line that reports the machine.
|
||||
|
||||
Two things come with it.
|
||||
|
||||
**The line under the table says what just changed.** A table shows what is; this
|
||||
@@ -443,6 +448,16 @@ 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.
|
||||
|
||||
Snapshots are taken **without memory and without quiescing**, and both are
|
||||
deliberate. Memory would keep the running machine's RAM too, so that a rollback
|
||||
came back mid-flight — at the price of writing all of it to the datastore every
|
||||
time, and of a rollback that restores a process tree along with the disks. What
|
||||
these are for is the moment before a patch, where coming back to a machine that
|
||||
*boots* is the point. Leaving quiescing off makes the disk state
|
||||
crash-consistent — what a machine finds after the plug is pulled, which a
|
||||
journalling filesystem handles and a database may not — and means a snapshot
|
||||
neither depends on VMware Tools running nor stops when they are not.
|
||||
|
||||
### Making a machine from a template
|
||||
|
||||
`p` in a template's action menu, `gvm new` on the command line. It is the one
|
||||
@@ -468,10 +483,17 @@ puts it somewhere other than the template's own.
|
||||
**It does not wait.** A clone copies every disk the template has, which is
|
||||
minutes to the half hour, and a list frozen for that long is a list nobody would
|
||||
start one from. The task hangs off the template in vCenter, so the row it was
|
||||
started from shows how far along it is in its TASK column — `^l` makes that move
|
||||
on its own, and announces the new machine on the line under the table when it
|
||||
exists. `gvm new` on the command line does wait: a script that gets its prompt
|
||||
back wants the machine to be there.
|
||||
started from shows how far along it is in its TASK column. `gvm new` on the
|
||||
command line does wait: a script that gets its prompt back wants the machine to
|
||||
be there.
|
||||
|
||||
**And it goes to that column.** The machine's sheet — which is where the menu
|
||||
was opened and a page of facts about the *template* — is put away, the list
|
||||
comes back with the cursor still on the row the clone hangs off, and live mode
|
||||
turns itself on if it was off, so the percentage moves on its own and the line
|
||||
under the table announces the new machine when it exists. `^l` ends it again.
|
||||
Naming the key that would make an invisible figure move is three steps of
|
||||
housekeeping after the one decision that mattered.
|
||||
|
||||
The rest of a template's menu is greyed out with "a template" next to it, because
|
||||
vSphere will not start one, snapshot one or reconfigure one. They are greyed
|
||||
@@ -528,8 +550,11 @@ are open —
|
||||
linux-static
|
||||
windows-domain
|
||||
|
||||
— and then one line for the address. Empty means DHCP on gvm's own road, and
|
||||
whatever the specification says on the vCenter's.
|
||||
— and then one line for the address, which says in words what leaving it empty
|
||||
would do:
|
||||
|
||||
address for web05, or empty for DHCP:
|
||||
address for web05, or empty to leave it to linux-static:
|
||||
|
||||
Where that picker does **not** appear, the confirmation says why rather than
|
||||
leaving a step to look broken — neither road is open, because the vCenter holds
|
||||
@@ -541,6 +566,89 @@ no specifications (or will not say) *and* `~/.gvmrc` has not been told the site:
|
||||
`gvm new --specs` lists what a server holds, and prints the server's own words
|
||||
when it will not say.
|
||||
|
||||
#### An address it fetches rather than one you look up
|
||||
|
||||
gvm new --from ubuntu-tpl --name web05 --ip auto
|
||||
|
||||
Where the site has a tool that hands addresses out, `--ip auto` asks it instead
|
||||
of making you look one up first. In the list the same word does it, and it is
|
||||
already standing in the line — a site with a tool hands addresses out from it,
|
||||
so that is the answer Enter gives:
|
||||
|
||||
address for web05, "auto" for one from dns, empty for DHCP: auto
|
||||
|
||||
It is offered rather than made the meaning of the empty line, so every answer
|
||||
keeps the meaning it had: erased back to nothing it is DHCP again, or whatever
|
||||
the chosen specification says. And Enter commits nothing — an address fetched
|
||||
here is handed straight back when the deployment is not confirmed.
|
||||
|
||||
It shells out to `dns`, the Infoblox helper — found on the path by that name, or
|
||||
named as `dnstool` in `~/.gvmrc` where it lives somewhere else. Everything it
|
||||
knows (the appliance, the credentials, the default domain) stays its own; a
|
||||
second copy of any of that inside gvm would be a second thing to keep right.
|
||||
`gvm config` says whether one was found, because "why was I not offered that"
|
||||
is the question that follows an option quietly not being there.
|
||||
|
||||
**The name has to be free.** It is asked before anything is added, and a name
|
||||
the appliance already knows is refused rather than added to — what `dns -a`
|
||||
would do with one is either refuse in worse words, or hang a second address on
|
||||
somebody else's host record:
|
||||
|
||||
web05 already exists (web05.fhi.mpg.de at 141.14.140.182) — give the machine
|
||||
another name, or free that one with 'dns -d web05'
|
||||
|
||||
Which is the same question gvm asks the vCenter about the machine's name, one
|
||||
answer short of the same answer.
|
||||
|
||||
**And the address is said where it stays.** In the confirmation, and again in
|
||||
the line that is left on the screen afterwards — this is a number somebody
|
||||
writes down:
|
||||
|
||||
web05 is being made at 141.14.140.182 — live on, ubuntu-tpl shows how far
|
||||
along it is
|
||||
|
||||
Three more things about it are deliberate:
|
||||
|
||||
* **It is asked with `-j`, and nothing older than v2.5.0 is asked at all.**
|
||||
From that version the helper answers in an envelope: `ok`, the reason in an
|
||||
`error` field of its own, the record — and an exit status that agrees with it.
|
||||
|
||||
{"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
|
||||
{"ok":false,"action":"showhost","error":"host '...' not found"}
|
||||
|
||||
What came before said the same things in a sentence and **exited 0 while
|
||||
refusing**, which is what an earlier version of this got wrong by believing
|
||||
exit statuses. Rather than keep reading both, gvm asks the helper its version
|
||||
once and uses it only from 2.5.0 up. An older one is not silently ignored —
|
||||
"there is none" and "the one you have is too old" send somebody to two
|
||||
different places, so it says which:
|
||||
|
||||
/usr/local/bin/dns is 2.4.4, and gvm wants 2.5.0 or newer — 'dns --update' fetches it
|
||||
|
||||
`gvm config` says the same thing when nothing is being deployed.
|
||||
|
||||
An appliance that cannot be reached is an error and not a free name:
|
||||
concluding "nobody has this name" from a server that is down is the last
|
||||
thing to do before asking it for an address.
|
||||
* **The record is read back**, not taken from the sentence the helper prints.
|
||||
`OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'` is written for a
|
||||
person and gets reworded between versions; the record is the appliance's own
|
||||
answer to the same question, and where the two disagree the record is what the
|
||||
machine will have.
|
||||
* **It is fetched before the confirmation**, so that the question says the
|
||||
address rather than a promise of one:
|
||||
|
||||
make web05 from ubuntu-tpl on v308? y = yes, anything else = no
|
||||
in prod, powered off · named and addressed for fhi.mpg.de at 141.14.140.182 (web05.fhi.mpg.de, from dns)
|
||||
|
||||
* **And given back where it is not used.** A deployment abandoned at that
|
||||
question, or one the vCenter then refuses, releases the address rather than
|
||||
leaving a record behind for a machine that was never made.
|
||||
|
||||
The name asked for is the machine's own — the short one, since the helper puts
|
||||
the record in the site's default domain itself — which is also the hostname the
|
||||
guest is given, so the two cannot disagree.
|
||||
|
||||
Three things it refuses rather than guesses at, on either road:
|
||||
|
||||
* **An address with no netmask to go with it.** Where the specification's
|
||||
@@ -1032,6 +1140,19 @@ wrong quietly:
|
||||
specifications, and one that will not let them be read, both used to skip the
|
||||
question in silence — and that the reason and the placement both fit on the
|
||||
line they share
|
||||
* that a name already in the appliance is refused before anything is added to
|
||||
it, that an appliance which cannot be reached is not mistaken for a free
|
||||
name, and that a helper older than 2.5.0 is refused by name and version
|
||||
rather than talked to — against a stand-in, since a suite that called the
|
||||
real one would be editing the institute's network every time it ran
|
||||
* that the version is read off the tool's own, and not off the toolbox version
|
||||
in the same line — which is the wrong answer that check exists to prevent
|
||||
* that an address fetched from the site's tool is read back from the record
|
||||
rather than from its printed sentence, that the tool's own words come back
|
||||
when it refuses, that a fetched address is given back when the deployment
|
||||
does not happen, and that nothing is ever asked of it for an address that was
|
||||
typed — against a stand-in, because a test suite that called the real one
|
||||
would be editing the institute's network every time it ran
|
||||
* that the specification gvm writes itself carries the site's five settings and
|
||||
this machine's two, invents nothing where an address was not given, and is
|
||||
refused for a Windows template and for a configuration that is not complete —
|
||||
|
||||
+93
-21
@@ -594,8 +594,14 @@ func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
|
||||
// minutes to the half hour, and a list that is frozen for that long is a list
|
||||
// nobody will start one from. vCenter carries the task either way, and it hangs
|
||||
// off the template — so the row this was started from shows how far along it is
|
||||
// in its TASK column, and ^l makes that move on its own. The machine itself
|
||||
// turns up in the list when it exists, which live mode announces.
|
||||
// in its TASK column. The machine itself turns up in the list when it exists,
|
||||
// which live mode announces.
|
||||
//
|
||||
// Which is why the end of it is a screen and not only a sentence: the sheet
|
||||
// goes away, the list comes back with the cursor still on the template, and
|
||||
// live mode turns itself on. Telling somebody that a figure they cannot see
|
||||
// exists, and which key would make it move, is three steps of housekeeping
|
||||
// after the one decision that mattered.
|
||||
//
|
||||
// The question at the end is the plain one. Nothing is lost here; something is
|
||||
// made, and what has to be read before it is made is *where* — which is why
|
||||
@@ -657,7 +663,7 @@ func (b *browser) deploy(r vmRow) {
|
||||
// that runs off the edge loses whichever half was put last. `gvm new
|
||||
// --specs` is where the server's own words are.
|
||||
b.deployAsk(r, src, target, name, deployOpts{},
|
||||
"the customisation specifications could not be read (gvm new --specs)")
|
||||
deployStep{note: "the customisation specifications could not be read (gvm new --specs)"})
|
||||
return
|
||||
case len(specs) == 0 && !own:
|
||||
why := SF("%s has no customisation specifications", r.vc.Name)
|
||||
@@ -667,7 +673,7 @@ func (b *browser) deploy(r vmRow) {
|
||||
why = SF("no specifications on %s, and %s has no %s", r.vc.Name,
|
||||
configFile(), strings.Join(miss, ", "))
|
||||
}
|
||||
b.deployAsk(r, src, target, name, deployOpts{}, why)
|
||||
b.deployAsk(r, src, target, name, deployOpts{}, deployStep{note: why})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -688,11 +694,13 @@ func (b *browser) deploy(r vmRow) {
|
||||
b.choose(SF("customise %s how", name), r, lines, func(i int) {
|
||||
switch kinds[i] {
|
||||
case "":
|
||||
b.deployAsk(r, src, target, name, deployOpts{}, "")
|
||||
b.deployAsk(r, src, target, name, deployOpts{}, deployStep{})
|
||||
case "-":
|
||||
b.deployAddress(r, src, target, name, deployOpts{st: b.site})
|
||||
b.deployAddress(r, src, target, name,
|
||||
deployOpts{how: customSite, st: b.site, dns: b.dns})
|
||||
default:
|
||||
b.deployAddress(r, src, target, name, deployOpts{spec: kinds[i], st: b.site})
|
||||
b.deployAddress(r, src, target, name,
|
||||
deployOpts{how: customSpec, spec: kinds[i], st: b.site, dns: b.dns})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -703,22 +711,58 @@ func (b *browser) deploy(r vmRow) {
|
||||
//
|
||||
// Empty is an answer: it leaves whatever the specification says, which is
|
||||
// usually DHCP, and that is the ordinary case on a network that hands out
|
||||
// addresses.
|
||||
// addresses. Where the site has a tool of its own, the ordinary case is the
|
||||
// other one — so "auto" is in the line when the question appears, and Enter
|
||||
// takes it.
|
||||
func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts) {
|
||||
// Empty means two different things and the question says which: with a
|
||||
// specification of the vCenter's it leaves whatever that one says, and with
|
||||
// gvm's own it leaves the adapter on DHCP.
|
||||
ask := SF("address for %s (empty = DHCP): ", name)
|
||||
if !opts.building() {
|
||||
ask = SF("address for %s (empty = as %s says): ", name, opts.spec)
|
||||
// Empty is an answer, and it means two different things: with gvm's own
|
||||
// specification it leaves the adapter on DHCP, and with one of the
|
||||
// vCenter's it leaves whatever that one says. The question says which, in
|
||||
// words rather than in the shorthand it used to use — "(empty = as
|
||||
// linux-static says)" reads as a sentence with a word missing even when the
|
||||
// name is there.
|
||||
//
|
||||
// And where the site has a tool that hands addresses out, the word for
|
||||
// that is offered too — an address one does not have to look up first is
|
||||
// the whole point of it being there.
|
||||
leave := "empty for DHCP"
|
||||
if opts.how == customSpec {
|
||||
leave = "empty to leave it to " + opts.spec
|
||||
}
|
||||
ip, ok := b.input(ask)
|
||||
ask := SF("address for %s, %s: ", name, leave)
|
||||
|
||||
// And where there is a tool, its word is standing in the line already, so
|
||||
// Enter is the whole answer. That is what the tool is configured for: a
|
||||
// site that has one hands out addresses from it, and typing "auto" every
|
||||
// time is asking somebody to confirm the ordinary case by hand.
|
||||
//
|
||||
// Offered rather than made the meaning of the empty line, so that every
|
||||
// answer keeps the meaning it had: erased back to nothing it is DHCP again,
|
||||
// or whatever the specification says. Nor does pressing Enter commit
|
||||
// anything — an address fetched here is handed straight back where the
|
||||
// deployment is not confirmed (deployStep.giveBack).
|
||||
preset := ""
|
||||
if opts.dns.there() {
|
||||
ask = SF("address for %s, %q for one from dns, %s: ", name, autoIP, leave)
|
||||
preset = autoIP
|
||||
}
|
||||
|
||||
ip, ok := b.inputWith(ask, preset)
|
||||
if !ok {
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return
|
||||
}
|
||||
opts.ip = strings.TrimSpace(ip)
|
||||
b.deployAsk(r, src, t, name, opts, "")
|
||||
|
||||
// Fetched here rather than at the confirmation, so that the question can
|
||||
// say the address the machine will have. give hands it back where the
|
||||
// deployment does not happen after all.
|
||||
opts, rec, give, err := fetchAddress(opts, name)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.deployAsk(r, src, t, name, opts, deployStep{rec: rec, give: give})
|
||||
}
|
||||
|
||||
// deployAsk is the last step: what will happen, in full, and then the machine.
|
||||
@@ -726,15 +770,16 @@ func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name
|
||||
// note is why a step did not happen — no customisation specifications on the
|
||||
// server, or none that could be read. A step that is skipped without a word
|
||||
// looks like one that is broken.
|
||||
func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts, note string) {
|
||||
func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name string,
|
||||
opts deployOpts, step deployStep) {
|
||||
// One line holds all of this, and a terminal cuts it from the right. So
|
||||
// what is unexpected goes first: the placement is the same every time and
|
||||
// the machine will show it afterwards, while "there was no question about
|
||||
// customisation, and here is why" is the thing somebody is looking for and
|
||||
// will not scroll to find.
|
||||
var parts []string
|
||||
if note != "" {
|
||||
parts = append(parts, note)
|
||||
if step.note != "" {
|
||||
parts = append(parts, step.note)
|
||||
}
|
||||
parts = append(parts, "in "+t.describe()+", powered off")
|
||||
|
||||
@@ -748,6 +793,9 @@ func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name stri
|
||||
}
|
||||
if opts.ip != "" {
|
||||
with += " at " + opts.ip
|
||||
if step.rec.Name != "" {
|
||||
with += " (" + step.rec.Name + ", from dns)"
|
||||
}
|
||||
}
|
||||
parts = append(parts, with)
|
||||
if toolsMissing(r.sess, src.ref) {
|
||||
@@ -757,17 +805,41 @@ func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name stri
|
||||
where := strings.Join(parts, " · ")
|
||||
|
||||
if !b.askWith(SF("make %s from %s on %s?", name, src.name, r.vc.Name), where) {
|
||||
step.giveBack() // nothing was made, so nothing keeps a fetched address
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return
|
||||
}
|
||||
|
||||
b.working(SF("starting %s ...", name))
|
||||
if _, err := startDeploy(r.sess, src, t, name, opts); err != nil {
|
||||
step.giveBack()
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.done(SF("%s is being made — %s shows how far along it is, and it appears in the list when it exists",
|
||||
name, src.name))
|
||||
// And then the screen it can be watched on. This was started from the
|
||||
// template's sheet — a page of facts about the machine being copied, which
|
||||
// is not what anybody is here for now. The list is: the clone's progress
|
||||
// stands in the TASK column of the row the cursor is already on, the new
|
||||
// machine turns up there when it exists, and the line under the table says
|
||||
// so. Live mode goes with it, or the percentage would sit where it was
|
||||
// until somebody pressed a key.
|
||||
//
|
||||
// Decided before done, which is what puts the task on the row, and applied
|
||||
// after it, so that the interval it schedules is the busy one.
|
||||
watching := ""
|
||||
if !b.live {
|
||||
watching = "live on, "
|
||||
}
|
||||
b.closeDetail()
|
||||
|
||||
// The address is in the line that survives, not only in the question that
|
||||
// was answered a moment ago: it is the one thing here somebody writes down.
|
||||
at := ""
|
||||
if opts.ip != "" && opts.customising() {
|
||||
at = " at " + opts.ip
|
||||
}
|
||||
b.done(SF("%s is being made%s — %s%s shows how far along it is", name, at, watching, src.name))
|
||||
b.watchLive()
|
||||
}
|
||||
|
||||
// working puts a line on the screen before an operation that will block the loop
|
||||
|
||||
@@ -759,8 +759,9 @@ type browser struct {
|
||||
events []eventLine
|
||||
eventsOf string // the id of the machine they are of
|
||||
|
||||
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
|
||||
site site // what a machine made from a template is told about the network
|
||||
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
|
||||
site site // what a machine made from a template is told about the network
|
||||
dns dnsHelper // the site's address helper, where this machine has one
|
||||
|
||||
// Live mode (live.go): the list re-reading itself on a timer rather than on
|
||||
// a keystroke. hist is what the trend column is drawn from, kept here
|
||||
@@ -816,7 +817,8 @@ func browseVMs(targets []VCenter, filter string, cfg Config) error {
|
||||
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
|
||||
}
|
||||
|
||||
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH, site: cfg.site()}
|
||||
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH,
|
||||
site: cfg.site(), dns: dnsTool(cfg.DNSTool)}
|
||||
defer func() { closeSessions(b.sessions) }()
|
||||
|
||||
PF("asking %s ...\n", vcNames(targets))
|
||||
@@ -1010,13 +1012,7 @@ func (b *browser) detailKey(k key) {
|
||||
|
||||
switch k.special {
|
||||
case keyEsc, keyEnter, keyBackspace, keyLeft:
|
||||
b.detail, b.dscroll = nil, 0
|
||||
// The events go with the visit, not with the machine: coming back to a
|
||||
// sheet half an hour later and finding half-hour-old events under a
|
||||
// label that says nothing about when they were read would be the one
|
||||
// stale thing on an otherwise freshly read screen. They are one
|
||||
// keystroke away again.
|
||||
b.events, b.eventsOf = nil, ""
|
||||
b.closeDetail()
|
||||
b.setStatus("", "")
|
||||
case keyCtrlA:
|
||||
b.openMenu()
|
||||
@@ -1304,8 +1300,18 @@ func (e *editor) key(k key) (finished, accepted bool) {
|
||||
// input reads one line in the status area and reports whether it was finished
|
||||
// rather than abandoned. It has its own key loop, so nothing that is typed here
|
||||
// reaches the filter.
|
||||
func (b *browser) input(label string) (string, bool) {
|
||||
b.edit = &editor{label: label}
|
||||
func (b *browser) input(label string) (string, bool) { return b.inputWith(label, "") }
|
||||
|
||||
// inputWith is the same with an answer already standing in the line, for a
|
||||
// question that has one ordinary answer worth offering: Enter takes it, and
|
||||
// Backspace is how it is refused. It is a default one can see — which is the
|
||||
// only kind worth having, since a question whose answer is decided by what was
|
||||
// not typed is one nobody knows they have agreed to.
|
||||
//
|
||||
// The cursor sits at the end of it, so typing carries on from the answer rather
|
||||
// than into the middle of it.
|
||||
func (b *browser) inputWith(label, preset string) (string, bool) {
|
||||
b.edit = &editor{label: label, runes: []rune(preset), cursor: len([]rune(preset))}
|
||||
defer func() { b.edit = nil }()
|
||||
|
||||
for {
|
||||
@@ -1715,6 +1721,19 @@ func (b *browser) openDetail() {
|
||||
b.dscroll = 0
|
||||
}
|
||||
|
||||
// closeDetail puts the sheet away and leaves the list underneath it. It is what
|
||||
// Esc does, and also what an action does when what happens next is to be
|
||||
// watched in the table rather than read on one machine's page (actions.go).
|
||||
//
|
||||
// The events go with the visit, not with the machine: coming back to a sheet
|
||||
// half an hour later and finding half-hour-old events under a label that says
|
||||
// nothing about when they were read would be the one stale thing on an
|
||||
// otherwise freshly read screen. They are one keystroke away again.
|
||||
func (b *browser) closeDetail() {
|
||||
b.detail, b.dscroll = nil, 0
|
||||
b.events, b.eventsOf = nil, ""
|
||||
}
|
||||
|
||||
// eventSheet is the history as sheet lines: the label on the first, each line in
|
||||
// the colour of its own severity. It is appended to the sheet rather than built
|
||||
// into vmDetail because vmDetail asks nothing of the network and this is the one
|
||||
|
||||
@@ -380,6 +380,29 @@ func TestTheSheetClosesWhenItsMachineGoes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the sheet leaves nothing of the visit behind. The events are the part
|
||||
// that would keep: they are read once, on request, and a sheet opened again
|
||||
// half an hour later must not show them under a label that says nothing about
|
||||
// when they were read. Esc does this, and so does a deployment, which puts the
|
||||
// sheet away to leave the list watching the clone (actions.go).
|
||||
func TestClosingTheSheetForgetsTheVisit(t *testing.T) {
|
||||
b := testBrowser("ubuntu-tpl", "web01")
|
||||
b.applySort()
|
||||
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
|
||||
b.eventsOf = b.current().id()
|
||||
b.openDetail()
|
||||
b.dscroll = 4
|
||||
|
||||
b.closeDetail()
|
||||
|
||||
if b.detail != nil || b.dscroll != 0 {
|
||||
t.Errorf("the sheet is still there: %d lines, scrolled to %d", len(b.detail), b.dscroll)
|
||||
}
|
||||
if b.events != nil || b.eventsOf != "" {
|
||||
t.Errorf("the events of %q were kept: %v", b.eventsOf, b.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type Config struct {
|
||||
// that are about the one machine.
|
||||
Domain string // fhi-berlin.mpg.de
|
||||
DNS string // one or more resolvers, separated by commas
|
||||
DNSTool string // the site's own address helper; unset means "dns" on the path
|
||||
Netmask string // 255.255.255.0
|
||||
Gateway string // 10.0.0.1
|
||||
Timezone string // Europe/Berlin
|
||||
@@ -489,6 +490,7 @@ func applyConfig(c *Config, m map[string]string) {
|
||||
set("ssh", &c.SSH)
|
||||
set("domain", &c.Domain)
|
||||
set("dns", &c.DNS)
|
||||
set("dnstool", &c.DNSTool)
|
||||
set("netmask", &c.Netmask)
|
||||
set("gateway", &c.Gateway)
|
||||
set("timezone", &c.Timezone)
|
||||
@@ -560,6 +562,7 @@ func applyEnv(c *Config) {
|
||||
env("GVM_SSH", &c.SSH)
|
||||
env("GVM_DOMAIN", &c.Domain)
|
||||
env("GVM_DNS", &c.DNS)
|
||||
env("GVM_DNSTOOL", &c.DNSTool)
|
||||
env("GVM_NETMASK", &c.Netmask)
|
||||
env("GVM_GATEWAY", &c.Gateway)
|
||||
env("GVM_TIMEZONE", &c.Timezone)
|
||||
@@ -679,7 +682,12 @@ func writeConfigTemplate(path string) {
|
||||
b.WriteString("# dns = 10.0.0.1, 10.0.0.2\n")
|
||||
b.WriteString("# netmask = 255.255.255.0\n")
|
||||
b.WriteString("# gateway = 10.0.0.1\n")
|
||||
b.WriteString("# timezone = Europe/Berlin\n\n")
|
||||
b.WriteString("# timezone = Europe/Berlin\n")
|
||||
b.WriteString("# An address can also be fetched rather than typed, where the site has a\n")
|
||||
b.WriteString("# tool for it: 'gvm new ... --ip auto'. Unset, gvm looks for 'dns' on the\n")
|
||||
b.WriteString("# path, and uses it from v2.5.0 up — older ones are said to be too old\n")
|
||||
b.WriteString("# rather than read.\n")
|
||||
b.WriteString("# dnstool = /usr/local/bin/dns\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")
|
||||
|
||||
@@ -51,32 +51,46 @@ type deployOpts struct {
|
||||
powerOn bool
|
||||
|
||||
// What the guest is told about itself, and where that comes from.
|
||||
//
|
||||
// Two ways, and they are the same two everywhere in vSphere. spec names a
|
||||
// customisation specification the vCenter already holds, where the site's
|
||||
// answers live in the vCenter and gvm overrides only this machine's two
|
||||
// facts. Without one, gvm writes the specification itself out of st — the
|
||||
// same site answers, kept in ~/.gvmrc instead. The second is the shorter
|
||||
// road for a site that has no specifications and does not want to keep a
|
||||
// set of them; the first is the only road for Windows, which needs a
|
||||
// Sysprep gvm does not write.
|
||||
spec string
|
||||
hostname string // empty means the machine's own name
|
||||
ip string // empty leaves the adapter on DHCP
|
||||
st site // the site's own answers, from the configuration
|
||||
how custom
|
||||
spec string // which one, when how is customSpec
|
||||
hostname string // empty means the machine's own name
|
||||
ip string // empty leaves the adapter on DHCP; autoIP fetches one
|
||||
st site // the site's own answers, from the configuration
|
||||
dns dnsHelper // the site's address helper, where there is one
|
||||
}
|
||||
|
||||
// customising reports whether anything is to be done to the guest at all.
|
||||
func (o deployOpts) customising() bool {
|
||||
return strings.TrimSpace(o.spec) != "" || strings.TrimSpace(o.ip) != "" ||
|
||||
strings.TrimSpace(o.hostname) != ""
|
||||
// autoAddress reports whether the address is to be fetched rather than typed.
|
||||
func (o deployOpts) autoAddress() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(o.ip), autoIP)
|
||||
}
|
||||
|
||||
// custom is where a customisation comes from. Two roads, and they are the same
|
||||
// two everywhere in vSphere: a specification the vCenter already holds, where
|
||||
// the site's answers live in the vCenter and gvm overrides only this machine's
|
||||
// two facts; or one gvm writes itself out of ~/.gvmrc, which is the same site
|
||||
// answers kept somewhere else. The second is the shorter road for a site that
|
||||
// has no specifications; the first is the only road for Windows.
|
||||
//
|
||||
// It is a decision carried rather than one worked out from the other fields.
|
||||
// Inferring it was a small mistake with a visible end: the interactive half
|
||||
// settles which road before it asks for an address, so at that moment the
|
||||
// fields are all still empty — which read as "no customisation at all" and
|
||||
// produced the question "address for web05 (empty = as says)", with a hole in
|
||||
// it where the name of a specification that was never chosen would have gone.
|
||||
type custom int
|
||||
|
||||
const (
|
||||
customNone custom = iota // leave the guest as the template made it
|
||||
customSite // gvm writes the specification, from the configuration
|
||||
customSpec // one the vCenter holds, named in spec
|
||||
)
|
||||
|
||||
// customising reports whether anything is to be done to the guest at all.
|
||||
func (o deployOpts) customising() bool { return o.how != customNone }
|
||||
|
||||
// building reports whether gvm is to write the specification itself rather than
|
||||
// take one from the vCenter.
|
||||
func (o deployOpts) building() bool {
|
||||
return strings.TrimSpace(o.spec) == "" && o.customising()
|
||||
}
|
||||
func (o deployOpts) building() bool { return o.how == customSite }
|
||||
|
||||
// deployTarget is where the new machine will go, resolved from the template and
|
||||
// the options before anything is sent — so the question that is asked is the
|
||||
@@ -301,6 +315,56 @@ func cloneSpec(t deployTarget, opts deployOpts, custom *types.CustomizationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
// deployStep is what the steps before the confirmation left behind: why one of
|
||||
// them did not happen, what the address tool handed out, and how to give that
|
||||
// back. Three things that travel together, because they are all answers to
|
||||
// "what has happened so far" — and a confirmation with eight arguments is one
|
||||
// nobody can call correctly.
|
||||
type deployStep struct {
|
||||
note string // why a step was skipped, said on the confirmation
|
||||
rec dnsHost // the record the address came from, where one was fetched
|
||||
give func() // hands that address back
|
||||
}
|
||||
|
||||
// giveBack releases a fetched address, and does nothing where none was.
|
||||
func (s deployStep) giveBack() {
|
||||
if s.give != nil {
|
||||
s.give()
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAddress turns an address of "auto" into one the site's tool handed out,
|
||||
// and hands back the name it was registered under and a way to give it back.
|
||||
//
|
||||
// The record is made before the confirmation rather than after it, so that the
|
||||
// question says the address the machine will actually have rather than a
|
||||
// promise of one. That is worth a record being made for a deployment somebody
|
||||
// then abandons — as long as it is given back, which is what the returned
|
||||
// function is for.
|
||||
func fetchAddress(opts deployOpts, name string) (deployOpts, dnsHost, func(), error) {
|
||||
nothing := func() {}
|
||||
if !opts.autoAddress() {
|
||||
return opts, dnsHost{}, nothing, nil
|
||||
}
|
||||
if !opts.dns.there() {
|
||||
// Why, rather than that: a tool that is too old and no tool at all are
|
||||
// two different things to go and do something about.
|
||||
return opts, dnsHost{}, nothing, errf("%s — or give --ip an address", opts.dns.why())
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(opts.hostname)
|
||||
if host == "" {
|
||||
host = name
|
||||
}
|
||||
rec, err := dnsAdd(opts.dns, host)
|
||||
if err != nil {
|
||||
return opts, dnsHost{}, nothing, err
|
||||
}
|
||||
|
||||
opts.ip = rec.address()
|
||||
return opts, rec, func() { dnsRemove(opts.dns, host) }, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- telling the guest
|
||||
|
||||
// specNames are the customisation specifications this vCenter holds, in the
|
||||
@@ -653,12 +717,26 @@ func deployCLI(vc VCenter, template, name string, opts deployOpts, yes bool) err
|
||||
{"where", t.describe()},
|
||||
{"powered on", started},
|
||||
}
|
||||
// An address to be fetched is fetched here, before the question, so that the
|
||||
// question says the address rather than the promise of one. give hands it
|
||||
// back where the deployment does not happen after all.
|
||||
opts, rec, give, err := fetchAddress(opts, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.Name != "" {
|
||||
// Said here, where it stays on the screen, and not only inside the
|
||||
// confirmation: this is a number somebody writes down.
|
||||
PO(SF("%s: %s", rec.Name, rec.address()))
|
||||
}
|
||||
|
||||
if opts.customising() {
|
||||
// Resolved before the question, not after it: a specification that does
|
||||
// not exist, or one whose adapter has no netmask, is something to hear
|
||||
// about now rather than at the end of a clone.
|
||||
custom, err := customizationFor(s, opts, src, name)
|
||||
if err != nil {
|
||||
give()
|
||||
return err
|
||||
}
|
||||
how := opts.spec
|
||||
@@ -666,7 +744,11 @@ func deployCLI(vc VCenter, template, name string, opts deployOpts, yes bool) err
|
||||
how = "from " + configFile()
|
||||
}
|
||||
facts = append(facts, [2]string{"customise", how}, [2]string{"hostname", hostNameOf(custom)})
|
||||
facts = append(facts, [2]string{"address", addressOf(custom)})
|
||||
address := addressOf(custom)
|
||||
if rec.Name != "" {
|
||||
address += SF(" (%s, from %s)", rec.Name, opts.dns.path)
|
||||
}
|
||||
facts = append(facts, [2]string{"address", address})
|
||||
if opts.building() && opts.ip != "" && gatewayOffSubnet(opts.ip, opts.st) {
|
||||
PE(SF("the gateway %s is not on the same network as %s — the machine will not reach it",
|
||||
opts.st.gateway, opts.ip))
|
||||
@@ -678,17 +760,23 @@ func deployCLI(vc VCenter, template, name string, opts deployOpts, yes bool) err
|
||||
}
|
||||
ok, err := confirmFacts(vc, "make "+name+" from the template "+src.name, facts, yes)
|
||||
if err != nil || !ok {
|
||||
give() // nothing was made, so nothing keeps the address
|
||||
return err
|
||||
}
|
||||
|
||||
task, err := startDeploy(s, src, t, name, opts)
|
||||
if err != nil {
|
||||
give()
|
||||
return err
|
||||
}
|
||||
PO(SF("making %s from %s ...", name, src.name))
|
||||
if err := waitTask(s.ctx, task, cloneWait, SF("making %s", name)); err != nil {
|
||||
return err
|
||||
}
|
||||
PO(SF("%s is made", name))
|
||||
made := name + " is made"
|
||||
if opts.ip != "" && opts.customising() {
|
||||
made += " at " + opts.ip
|
||||
}
|
||||
PO(made)
|
||||
return nil
|
||||
}
|
||||
|
||||
+98
-1
@@ -365,7 +365,7 @@ func TestAMissingStepSaysWhyItIsMissing(t *testing.T) {
|
||||
kw.WriteString("n") // anything but y: the question is what is being checked
|
||||
|
||||
b.deployAsk(r, deploySource{name: "ubuntu-tpl"},
|
||||
deployTarget{where: "prod-cluster"}, "web05", deployOpts{}, note)
|
||||
deployTarget{where: "prod-cluster"}, "web05", deployOpts{}, deployStep{note: note})
|
||||
|
||||
frame := stripEscapes(<-drawn)
|
||||
pw.Close()
|
||||
@@ -570,3 +570,100 @@ func TestTheSiteComesOutOfTheConfiguration(t *testing.T) {
|
||||
t.Errorf("an empty one describes itself as %q", Config{}.site().describe())
|
||||
}
|
||||
}
|
||||
|
||||
// The question that asks for an address has to read as a sentence on both
|
||||
// roads. It did not: the interactive half settles which road before it asks,
|
||||
// so at that moment every field is still empty — which read as "no
|
||||
// customisation at all" and produced "address for web05 (empty = as says)",
|
||||
// a sentence with a hole where the name of a specification nobody had chosen
|
||||
// would have gone.
|
||||
func TestTheAddressQuestionReadsAsASentence(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "24")
|
||||
|
||||
tool, _ := fakeInfoblox(t) // for its there(), not for its answers
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
opts deployOpts
|
||||
want string
|
||||
}{
|
||||
{"gvm's own specification", deployOpts{how: customSite, st: testSite()}, "empty for DHCP"},
|
||||
{"one the vCenter holds", deployOpts{how: customSpec, spec: "linux-static"},
|
||||
"leave it to linux-static"},
|
||||
// With an address tool, the answer it would give is standing in the
|
||||
// line already: that is what the tool is there for, and the question
|
||||
// still has to say what the other two answers mean.
|
||||
{"a site with an address tool",
|
||||
deployOpts{how: customSite, st: testSite(), dns: tool},
|
||||
`"auto" for one from dns, empty for DHCP: auto`},
|
||||
} {
|
||||
r := templateRow("ubuntu-tpl")
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = pw
|
||||
drawn := make(chan string, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 1<<16)
|
||||
n, _ := pr.Read(buf)
|
||||
drawn <- string(buf[:n])
|
||||
}()
|
||||
|
||||
kr, kw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.keys = newKeyReader(kr)
|
||||
kw.WriteString("\x1b") // esc: the question is what is being read, not the answer
|
||||
|
||||
b.deployAddress(r, deploySource{name: "ubuntu-tpl"},
|
||||
deployTarget{where: "prod-cluster"}, "web05", c.opts)
|
||||
|
||||
frame := stripEscapes(<-drawn)
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
kw.Close()
|
||||
|
||||
if !strings.Contains(frame, c.want) {
|
||||
t.Errorf("%s: the question does not say %q:\n%s", c.what, c.want, lastLine(frame))
|
||||
}
|
||||
// No hole where a name should be, on either road.
|
||||
for _, hole := range []string{"as says", " says", "to : ", "empty = as"} {
|
||||
if strings.Contains(frame, hole) {
|
||||
t.Errorf("%s: the question has a hole in it (%q):\n%s", c.what, hole, lastLine(frame))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The road is carried, not worked out from the other fields — which is what
|
||||
// made that hole. Before an address or a name has been typed, the decision is
|
||||
// already the whole answer.
|
||||
func TestTheRoadIsCarriedNotGuessed(t *testing.T) {
|
||||
// Chosen, with nothing filled in yet: still a customisation, and still
|
||||
// gvm's own.
|
||||
empty := deployOpts{how: customSite, st: testSite()}
|
||||
if !empty.customising() {
|
||||
t.Error("choosing gvm's own specification does not count as customising")
|
||||
}
|
||||
if !empty.building() {
|
||||
t.Error("choosing gvm's own specification is not building one")
|
||||
}
|
||||
|
||||
// A specification of the vCenter's, likewise, before an address is typed.
|
||||
named := deployOpts{how: customSpec, spec: "linux-static"}
|
||||
if !named.customising() || named.building() {
|
||||
t.Errorf("a vCenter specification reads as customising=%v building=%v",
|
||||
named.customising(), named.building())
|
||||
}
|
||||
|
||||
// And nothing chosen is nothing done, whatever else is lying around.
|
||||
none := deployOpts{st: testSite()}
|
||||
if none.customising() || none.building() {
|
||||
t.Error("a deployment nobody asked to customise customises anyway")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
// dns.go — asking the site's own tool for an address.
|
||||
//
|
||||
// A machine made from a template needs a name and an address, and at this site
|
||||
// the address does not come out of gvm's head: there is an Infoblox behind a
|
||||
// helper called `dns`, and an address that is not in it is an address nobody
|
||||
// may use. So gvm asks it rather than inventing one, and only where it is
|
||||
// there — a copy of gvm on a laptop that has no such tool simply does not offer
|
||||
// the option.
|
||||
//
|
||||
// Everything here shells out. That is deliberate: the helper already knows the
|
||||
// site's Infoblox, its credentials and its default domain, and a second
|
||||
// implementation of any of that inside gvm would be a second thing to keep
|
||||
// right.
|
||||
//
|
||||
// What it does is not read-only, and that shapes the two rules this file holds
|
||||
// to. Nothing is allocated that was not asked for, by name, at the moment it is
|
||||
// asked for. And what is allocated and then not used is given back: a
|
||||
// deployment somebody abandons at the confirmation, or one the vCenter refuses,
|
||||
// must not leave a record behind for a machine that was never made.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dnsWait is how long the helper is given. It talks to an appliance over the
|
||||
// network, so it is not instant; it is also not a thing to wait minutes for
|
||||
// while a deployment is half set up.
|
||||
const dnsWait = 30 * time.Second
|
||||
|
||||
// autoIP is what is typed where an address would go to have one fetched. A
|
||||
// word rather than a flag of its own: --ip is already "what address does this
|
||||
// machine get", and "auto" is an answer to that question rather than a
|
||||
// different question.
|
||||
const autoIP = "auto"
|
||||
|
||||
// dnsHelper is the tool, where there is one gvm can use.
|
||||
//
|
||||
// Only v2.5.0 and up: from there it answers in JSON when asked with -j, with
|
||||
// the reason for a refusal in a field of its own and an exit status that agrees
|
||||
// with it. What came before said the same things in a sentence and exited 0
|
||||
// while refusing, which is a shape worth reading only as long as somebody is
|
||||
// still running one — and nobody here is.
|
||||
//
|
||||
// A tool that is too old is not the same as no tool at all, and why() keeps the
|
||||
// difference: "there is none" and "the one you have is too old to ask this of"
|
||||
// send somebody to two different places.
|
||||
type dnsHelper struct {
|
||||
path string // "" where none was found at all
|
||||
version [3]int // what it says it is
|
||||
usable bool // found, and new enough
|
||||
}
|
||||
|
||||
func (h dnsHelper) there() bool { return h.usable }
|
||||
|
||||
// dnsJSONFrom is the first version that answers in JSON, and so the first that
|
||||
// gvm will talk to.
|
||||
var dnsJSONFrom = [3]int{2, 5, 0}
|
||||
|
||||
func versionString(v [3]int) string { return SF("%d.%d.%d", v[0], v[1], v[2]) }
|
||||
|
||||
// why says what is in the way, for the refusal and for `gvm config`. Empty
|
||||
// where nothing is.
|
||||
func (h dnsHelper) why() string {
|
||||
switch {
|
||||
case h.usable:
|
||||
return ""
|
||||
case h.path == "":
|
||||
return SF("there is no address tool on this machine — put one on the path as 'dns', "+
|
||||
"or name it as dnstool in %s", configFile())
|
||||
case h.version == [3]int{}:
|
||||
return SF("%s does not say which version it is, and gvm wants %s or newer",
|
||||
h.path, versionString(dnsJSONFrom))
|
||||
}
|
||||
return SF("%s is %s, and gvm wants %s or newer — 'dns --update' fetches it",
|
||||
h.path, versionString(h.version), versionString(dnsJSONFrom))
|
||||
}
|
||||
|
||||
// dnsTool is the helper to use, or one that says why it cannot be used.
|
||||
//
|
||||
// The configuration may name it outright, for a machine where it is not on the
|
||||
// path; otherwise it is looked up by name, which is how it is found on the
|
||||
// machines it is installed on. Either way it is then asked how old it is —
|
||||
// once, here, because a probe per call would be three of them for one
|
||||
// deployment.
|
||||
func dnsTool(configured string) dnsHelper {
|
||||
path := ""
|
||||
if c := strings.TrimSpace(configured); c != "" {
|
||||
// Named but not there: nothing is offered, rather than quietly using a
|
||||
// different tool of the same name from somewhere on the path.
|
||||
if p, err := exec.LookPath(c); err == nil {
|
||||
path = p
|
||||
}
|
||||
} else if p, err := exec.LookPath("dns"); err == nil {
|
||||
path = p
|
||||
}
|
||||
if path == "" {
|
||||
return dnsHelper{}
|
||||
}
|
||||
|
||||
h := dnsHelper{path: path}
|
||||
if v, ok := dnsAskVersion(path); ok {
|
||||
h.version = v
|
||||
h.usable = !olderThan(v, dnsJSONFrom)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// dnsAskVersion asks the helper how old it is. A version that cannot be read is
|
||||
// not a version: gvm would rather say so than talk to something whose answers
|
||||
// it cannot predict.
|
||||
func dnsAskVersion(path string) ([3]int, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, path, "-v").CombinedOutput()
|
||||
if err != nil {
|
||||
return [3]int{}, false
|
||||
}
|
||||
return dnsVersion(string(out))
|
||||
}
|
||||
|
||||
// dnsVersion picks the version out of what -v says, which is a sentence about
|
||||
// itself:
|
||||
//
|
||||
// dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)
|
||||
//
|
||||
// Anchored on the bracket, because there are two versions in that line and only
|
||||
// the first is the tool's own. Taking whichever came first instead read the
|
||||
// toolbox's 0.5.0 the moment the tool's own could not be read — which is the
|
||||
// quiet wrong answer this whole check exists to avoid, and it turned up in a
|
||||
// test rather than in front of somebody.
|
||||
var dnsVersionRe = regexp.MustCompile(`\(v(\d+)\.(\d+)\.(\d+)`)
|
||||
|
||||
func dnsVersion(said string) ([3]int, bool) {
|
||||
m := dnsVersionRe.FindStringSubmatch(said)
|
||||
if m == nil {
|
||||
return [3]int{}, false
|
||||
}
|
||||
var v [3]int
|
||||
for i := range v {
|
||||
n, err := strconv.Atoi(m[i+1])
|
||||
if err != nil {
|
||||
return [3]int{}, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func olderThan(v, than [3]int) bool {
|
||||
for i := range v {
|
||||
if v[i] != than[i] {
|
||||
return v[i] < than[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dnsHost is the part of a host record gvm reads. The helper answers in the
|
||||
// appliance's own JSON, which has thirty fields; these are the two that say
|
||||
// what the machine is called and what it may use.
|
||||
type dnsHost struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []struct {
|
||||
Addr string `json:"ipv4addr"`
|
||||
} `json:"ipv4addrs"`
|
||||
}
|
||||
|
||||
// address is the first address of the record, or "" where it has none.
|
||||
func (h dnsHost) address() string {
|
||||
for _, a := range h.Addresses {
|
||||
if a.Addr != "" {
|
||||
return a.Addr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dnsName is the short name to ask for. The helper puts the record in the
|
||||
// site's default domain itself, so what it wants is the name and not a fully
|
||||
// qualified one — and a machine called web05.example.org would otherwise be
|
||||
// registered as web05.example.org.example.org.
|
||||
func dnsName(host string) string {
|
||||
name, _, _ := strings.Cut(strings.TrimSpace(host), ".")
|
||||
return name
|
||||
}
|
||||
|
||||
// dnsAnswer is one reply: whether the helper did the thing, what it said if it
|
||||
// did not, and the record where there is one.
|
||||
//
|
||||
// It is the envelope v2.5.0 and up put every reply in, kept as gvm's own type
|
||||
// so that the three calls above it read the same four fields rather than each
|
||||
// one unwrapping a reply for itself:
|
||||
//
|
||||
// {"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
|
||||
// {"ok":false,"action":"showhost","error":"host '...' not found"}
|
||||
type dnsAnswer struct {
|
||||
ok bool
|
||||
err string // the helper's own words, where it refused
|
||||
record json.RawMessage // the host record, where the call has one
|
||||
}
|
||||
|
||||
// notFound reports whether the refusal is the ordinary one: no such name. That
|
||||
// is an answer to "is this name free", not a failure to report.
|
||||
func (a dnsAnswer) notFound() bool {
|
||||
return !a.ok && strings.Contains(strings.ToLower(a.err), "not found")
|
||||
}
|
||||
|
||||
// dnsAsk is one call to the helper. -j because that is the only way gvm talks
|
||||
// to it, and -y throughout: it asks before it changes anything when it has a
|
||||
// terminal, and gvm has already asked.
|
||||
func dnsAsk(h dnsHelper, args ...string) (dnsAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
|
||||
defer cancel()
|
||||
|
||||
argv := append(append([]string{}, args...), "-j", "-y")
|
||||
out, err := exec.CommandContext(ctx, h.path, argv...).CombinedOutput()
|
||||
text := strings.TrimSpace(string(out))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Record json.RawMessage `json:"record"`
|
||||
}
|
||||
if jerr := json.Unmarshal([]byte(text), &env); jerr != nil {
|
||||
if err != nil && text == "" {
|
||||
return dnsAnswer{}, errf("%s: %w", h.path, err)
|
||||
}
|
||||
return dnsAnswer{}, errf("%s answered something that is not a reply: %s", h.path, firstLine(text))
|
||||
}
|
||||
|
||||
a := dnsAnswer{ok: env.OK, err: env.Error, record: env.Record}
|
||||
if !a.ok && a.err == "" {
|
||||
a.err = "it refused, without saying why"
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// dnsShow reads a host record. A name that is not there comes back as an empty
|
||||
// record and no error: "is this name free" is a question, and "no such name" is
|
||||
// its answer rather than a failure to report.
|
||||
//
|
||||
// Anything else that goes wrong is an error and stays one. Swallowing those
|
||||
// would turn an appliance nobody can reach into "the name is free", which is
|
||||
// the last thing to conclude before asking it for an address.
|
||||
func dnsShow(h dnsHelper, host string) (dnsHost, error) {
|
||||
a, err := dnsAsk(h, "-s", dnsName(host))
|
||||
if err != nil {
|
||||
return dnsHost{}, err
|
||||
}
|
||||
switch {
|
||||
case a.notFound():
|
||||
return dnsHost{}, nil
|
||||
case !a.ok:
|
||||
return dnsHost{}, errf("%s", a.err)
|
||||
case len(a.record) == 0:
|
||||
return dnsHost{}, errf("%s said nothing about %s", h.path, dnsName(host))
|
||||
}
|
||||
|
||||
var rec dnsHost
|
||||
if err := json.Unmarshal(a.record, &rec); err != nil {
|
||||
return dnsHost{}, errf("%s answered something that is not a host record: %s",
|
||||
h.path, firstLine(string(a.record)))
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// dnsAdd asks for an address for this name and hands back what was given.
|
||||
//
|
||||
// The record is read back rather than taken from what the helper said on the
|
||||
// way past. Its "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'" is
|
||||
// a sentence for a person, and a sentence is a thing that gets reworded between
|
||||
// versions; the record is the appliance's own answer to the same question. If
|
||||
// the two ever disagree, the record is what the machine will actually be given.
|
||||
func dnsAdd(h dnsHelper, host string) (dnsHost, error) {
|
||||
name := dnsName(host)
|
||||
if name == "" {
|
||||
return dnsHost{}, errf("there is no name to ask for an address for")
|
||||
}
|
||||
|
||||
// Asked first, and refused rather than added to. A name that is already in
|
||||
// the appliance belongs to something — and what `dns -a` would do with it
|
||||
// is either refuse, which is this message with worse wording, or hang a
|
||||
// second address on somebody else's host record, which is worse than
|
||||
// either. It is also the same question gvm asks the vCenter about the
|
||||
// machine's name (nameTaken), one answer short of the same answer.
|
||||
if rec, err := dnsShow(h, name); err != nil {
|
||||
return dnsHost{}, err
|
||||
} else if rec.Name != "" {
|
||||
taken := rec.Name
|
||||
if a := rec.address(); a != "" {
|
||||
taken += " at " + a
|
||||
}
|
||||
return dnsHost{}, errf("%s already exists (%s) — give the machine another name, "+
|
||||
"or free that one with 'dns -d %s'", name, taken, name)
|
||||
}
|
||||
|
||||
a, err := dnsAsk(h, "-a", name)
|
||||
if err != nil {
|
||||
return dnsHost{}, err
|
||||
}
|
||||
if !a.ok {
|
||||
return dnsHost{}, errf("%s", a.err)
|
||||
}
|
||||
|
||||
// Read back rather than taken from what the reply carried. Both dialects
|
||||
// say something about what they just made, and neither has to be trusted
|
||||
// for it: the record is the appliance's own answer to the same question,
|
||||
// asked after the fact, and it is what the machine will actually have.
|
||||
rec, err := dnsShow(h, name)
|
||||
if err != nil {
|
||||
return dnsHost{}, err
|
||||
}
|
||||
if rec.address() == "" {
|
||||
return dnsHost{}, errf("%s made %s, but the record has no address in it", h.path, name)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// dnsRemove gives an address back. It is called where a deployment did not
|
||||
// happen after all, so its failure is worth saying and not worth stopping for:
|
||||
// the machine was not made either way, and what is left behind is a record
|
||||
// somebody can delete by hand.
|
||||
func dnsRemove(h dnsHelper, host string) error {
|
||||
a, err := dnsAsk(h, "-d", dnsName(host))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !a.ok {
|
||||
return errf("%s", a.err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstLine keeps a message from a tool to one line, for a status line that has
|
||||
// one.
|
||||
func firstLine(s string) string {
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
|
||||
return line
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Nothing in this file may touch the real helper. `dns -a` takes an address out
|
||||
// of the site's Infoblox and `dns -d` gives one back, and a test suite that did
|
||||
// either would be editing the institute's network every time somebody ran it.
|
||||
// So what is under test is everything around the call: what gvm asks for, what
|
||||
// it makes of the answer, and what it does when the answer is no.
|
||||
//
|
||||
// The stand-in answers the way v2.5.0 does, measured rather than assumed: an
|
||||
// envelope with ok, the reason in an error field of its own, and exit 1 where
|
||||
// it refused.
|
||||
//
|
||||
// {"ok":true, "action":"showhost","record":{...}}
|
||||
// {"ok":false,"action":"showhost","error":"host ... not found"}
|
||||
func fakeDNS(t *testing.T, version, body string) (tool, log string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
tool = filepath.Join(dir, "dns")
|
||||
log = filepath.Join(dir, "asked")
|
||||
|
||||
script := `#!/bin/sh
|
||||
echo "$@" >> ` + log + `
|
||||
if [ "$1" = "-v" ]; then
|
||||
echo "dns - infoblox helper (v` + version + ` (1282), toolbox v0.5.0, mwx'2026)"
|
||||
exit 0
|
||||
fi
|
||||
` + body + `
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(tool, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tool, log
|
||||
}
|
||||
|
||||
// fakeInfoblox is that stand-in with a memory: a name is not there until it has
|
||||
// been added, and is gone again once it has been deleted — which is the whole
|
||||
// shape of what gvm does with it.
|
||||
func fakeInfoblox(t *testing.T) (dnsHelper, string) {
|
||||
t.Helper()
|
||||
state := filepath.Join(t.TempDir(), "records")
|
||||
|
||||
tool, log := fakeDNS(t, "2.5.0", `
|
||||
state=`+state+`
|
||||
rec() { printf '{"name":"%s.fhi.mpg.de","ipv4addrs":[{"ipv4addr":"141.14.140.182"}]}' "$1"; }
|
||||
ok() { printf '{"ok":true,"action":"%s","record":%s}\n' "$1" "$(rec $2)"; exit 0; }
|
||||
no() { printf '{"ok":false,"action":"%s","error":"%s"}\n' "$1" "$2"; exit 1; }
|
||||
done_() { printf '{"ok":true,"action":"%s"}\n' "$1"; exit 0; }
|
||||
|
||||
case "$1" in
|
||||
-s) if grep -qx "$2" $state 2>/dev/null; then ok showhost "$2"
|
||||
else no showhost "host $2.fhi.mpg.de not found"; fi ;;
|
||||
-a) if grep -qx "$2" $state 2>/dev/null; then no addhost "host $2.fhi.mpg.de already exists"
|
||||
else echo "$2" >> $state; done_ addhost; fi ;;
|
||||
-d) grep -vx "$2" $state > $state.tmp 2>/dev/null
|
||||
mv $state.tmp $state 2>/dev/null
|
||||
done_ delhost ;;
|
||||
esac`)
|
||||
|
||||
return dnsTool(tool), log
|
||||
}
|
||||
|
||||
// asked is what the helper was called with, one call per line, without the
|
||||
// version probe — that one is gvm working out which dialect it is talking to,
|
||||
// not part of what any of these tests is about.
|
||||
func asked(t *testing.T, log string) []string {
|
||||
t.Helper()
|
||||
out, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var calls []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l != "" && !strings.HasPrefix(l, "-v") {
|
||||
calls = append(calls, l)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// Which versions gvm will talk to. Below 2.5.0 the helper answers in sentences
|
||||
// and exits 0 while refusing, and gvm no longer reads that — so it says what is
|
||||
// in the way rather than talking to it anyway.
|
||||
func TestOnlyANewEnoughHelperIsUsed(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
version string
|
||||
usable bool
|
||||
}{
|
||||
{"2.5.0", true},
|
||||
{"2.5.1", true},
|
||||
{"2.6.0", true},
|
||||
{"3.0.0", true},
|
||||
{"2.4.4", false},
|
||||
{"1.9.9", false},
|
||||
} {
|
||||
tool, _ := fakeDNS(t, c.version, `echo '{"ok":true,"action":"noop"}'`)
|
||||
h := dnsTool(tool)
|
||||
if h.there() != c.usable {
|
||||
t.Errorf("v%s: usable = %v, want %v", c.version, h.there(), c.usable)
|
||||
}
|
||||
if c.usable {
|
||||
continue
|
||||
}
|
||||
// And says which of the two things is wrong: a tool that is too old is
|
||||
// not the same as no tool, and they send somebody to different places.
|
||||
why := h.why()
|
||||
if !strings.Contains(why, c.version) || !strings.Contains(why, "2.5.0") {
|
||||
t.Errorf("v%s: it says %q, which does not name both versions", c.version, why)
|
||||
}
|
||||
if !strings.Contains(why, "dns --update") {
|
||||
t.Errorf("v%s: it does not say what to do about it: %q", c.version, why)
|
||||
}
|
||||
}
|
||||
|
||||
// There are two versions in that line and only the first is the tool's own:
|
||||
// a helper whose own version cannot be read must not be taken for its
|
||||
// toolbox's.
|
||||
if v, ok := dnsVersion("dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)"); !ok ||
|
||||
v != [3]int{2, 5, 0} {
|
||||
t.Errorf("the tool's own version read as %v (%v)", v, ok)
|
||||
}
|
||||
if v, ok := dnsVersion("dns - infoblox helper (vnonsense (1282), toolbox v0.5.0, mwx'2026)"); ok {
|
||||
t.Errorf("the toolbox's version was taken for the tool's: %v", v)
|
||||
}
|
||||
|
||||
// A helper that will not say which version it is, and one that is not there
|
||||
// at all: two more reasons, each said as itself.
|
||||
tool, _ := fakeDNS(t, "no-version-here", `echo hello`)
|
||||
h := dnsTool(tool)
|
||||
if h.there() {
|
||||
t.Error("a helper that does not say its version was used anyway")
|
||||
}
|
||||
if !strings.Contains(h.why(), "does not say which version") {
|
||||
t.Errorf("it says %q", h.why())
|
||||
}
|
||||
if none := (dnsHelper{}); !strings.Contains(none.why(), "no address tool") {
|
||||
t.Errorf("no tool at all says %q", none.why())
|
||||
}
|
||||
}
|
||||
|
||||
// The record is read back rather than taken from whatever the reply carried:
|
||||
// the helper says something about what it just made, and it does not have to be
|
||||
// trusted for it.
|
||||
func TestAnAddressIsReadBackFromTheRecord(t *testing.T) {
|
||||
h, log := fakeInfoblox(t)
|
||||
|
||||
rec, err := dnsAdd(h, "web05")
|
||||
if err != nil {
|
||||
t.Fatalf("dnsAdd: %v", err)
|
||||
}
|
||||
if got := rec.address(); got != "141.14.140.182" {
|
||||
t.Errorf("the address came back as %q", got)
|
||||
}
|
||||
if rec.Name != "web05.fhi.mpg.de" {
|
||||
t.Errorf("the name came back as %q", rec.Name)
|
||||
}
|
||||
|
||||
// Asked whether the name is free, then to add it, then what it made — and
|
||||
// never without -j and -y: the one is the only dialect gvm reads, the other
|
||||
// is what keeps it from stopping to ask a question nobody is there to
|
||||
// answer.
|
||||
calls := asked(t, log)
|
||||
if len(calls) != 3 || !strings.HasPrefix(calls[0], "-s web05") ||
|
||||
!strings.HasPrefix(calls[1], "-a web05") || !strings.HasPrefix(calls[2], "-s web05") {
|
||||
t.Fatalf("it was asked %v", calls)
|
||||
}
|
||||
for _, c := range calls {
|
||||
if !strings.Contains(c, "-y") || !strings.Contains(c, "-j") {
|
||||
t.Errorf("a call went out without -j and -y: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A name already in the appliance is refused before anything is added to it,
|
||||
// with what is in the way and how to clear it.
|
||||
func TestANameThatIsTakenIsRefused(t *testing.T) {
|
||||
h, log := fakeInfoblox(t)
|
||||
|
||||
if _, err := dnsAdd(h, "web05"); err != nil {
|
||||
t.Fatalf("the first one went wrong: %v", err)
|
||||
}
|
||||
before := len(asked(t, log))
|
||||
|
||||
_, err := dnsAdd(h, "web05")
|
||||
if err == nil {
|
||||
t.Fatal("a name that is already there was taken again")
|
||||
}
|
||||
for _, want := range []string{"already exists", "141.14.140.182", "dns -d web05"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
calls := asked(t, log)
|
||||
if len(calls) != before+1 || !strings.HasPrefix(calls[before], "-s web05") {
|
||||
t.Errorf("more than a look was taken: %v", calls[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// The helper's own words come back when it refuses for any other reason.
|
||||
func TestTheHelpersOwnWordsComeBack(t *testing.T) {
|
||||
tool, _ := fakeDNS(t, "2.5.0",
|
||||
`echo '{"ok":false,"action":"addhost","error":"no free address in the network"}'; exit 1`)
|
||||
|
||||
_, err := dnsAdd(dnsTool(tool), "web05")
|
||||
if err == nil {
|
||||
t.Fatal("a helper that refused was taken for one that agreed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no free address") {
|
||||
t.Errorf("its words did not come back: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An appliance that cannot be reached is not a free name. Concluding "nobody
|
||||
// has this name" from a server that is down is the last thing to do before
|
||||
// asking it for an address.
|
||||
func TestAnApplianceThatIsDownIsNotAFreeName(t *testing.T) {
|
||||
tool, _ := fakeDNS(t, "2.5.0",
|
||||
`echo '{"ok":false,"action":"showhost","error":"cannot reach infoblox"}'; exit 1`)
|
||||
h := dnsTool(tool)
|
||||
|
||||
if _, err := dnsShow(h, "web05"); err == nil {
|
||||
t.Error("an appliance that could not be reached reported a free name")
|
||||
}
|
||||
if _, err := dnsAdd(h, "web05"); err == nil {
|
||||
t.Error("an address was asked for over an appliance that is down")
|
||||
}
|
||||
}
|
||||
|
||||
// A helper that agrees but leaves no record is not an answer either: the
|
||||
// deployment would be given an empty address and the machine would come up on
|
||||
// nothing.
|
||||
func TestAnAddressThatCannotBeReadBackIsNoAddress(t *testing.T) {
|
||||
tool, _ := fakeDNS(t, "2.5.0", `case "$1" in
|
||||
-s) echo '{"ok":true,"action":"showhost","record":{"name":"web05.fhi.mpg.de","ipv4addrs":[]}}' ;;
|
||||
-a) echo '{"ok":true,"action":"addhost"}' ;;
|
||||
esac`)
|
||||
if _, err := dnsAdd(dnsTool(tool), "web05"); err == nil {
|
||||
t.Fatal("an empty record was taken for an address")
|
||||
}
|
||||
|
||||
// And something that is not a reply at all.
|
||||
tool, _ = fakeDNS(t, "2.5.0", `echo 'not json at all'`)
|
||||
if _, err := dnsShow(dnsTool(tool), "web05"); err == nil {
|
||||
t.Fatal("a page of prose was taken for a reply")
|
||||
}
|
||||
}
|
||||
|
||||
// The helper puts the record in the site's own domain, so what it wants is a
|
||||
// name and not a fully qualified one — web05.example.org would otherwise be
|
||||
// registered as web05.example.org.example.org.
|
||||
func TestOnlyTheNameIsAskedFor(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"web05", "web05"},
|
||||
{"web05.fhi-berlin.mpg.de", "web05"},
|
||||
{" web05.example.org ", "web05"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := dnsName(c.in); got != c.want {
|
||||
t.Errorf("dnsName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An address that was fetched and then not used is given back — a deployment
|
||||
// abandoned at the confirmation must not leave a record behind for a machine
|
||||
// that was never made — and the name is free again afterwards.
|
||||
func TestAFetchedAddressIsGivenBack(t *testing.T) {
|
||||
h, log := fakeInfoblox(t)
|
||||
|
||||
opts := deployOpts{how: customSite, st: testSite(), ip: autoIP, dns: h}
|
||||
opts, rec, give, err := fetchAddress(opts, "web05")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAddress: %v", err)
|
||||
}
|
||||
if opts.ip != "141.14.140.182" {
|
||||
t.Errorf("the deployment was given %q as its address", opts.ip)
|
||||
}
|
||||
if rec.Name == "" {
|
||||
t.Error("the record came back without a name to show")
|
||||
}
|
||||
|
||||
give()
|
||||
if calls := asked(t, log); len(calls) == 0 || !strings.HasPrefix(calls[len(calls)-1], "-d web05") {
|
||||
t.Errorf("the address was not given back: %v", calls)
|
||||
}
|
||||
if rec, err := dnsShow(h, "web05"); err != nil || rec.Name != "" {
|
||||
t.Errorf("the name is still taken after being released: %+v (%v)", rec, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All of it happens only where the site has such a tool. Without one, "auto" is
|
||||
// refused with what to do about it, and an ordinary address is untouched either
|
||||
// way.
|
||||
func TestWithoutAToolAutoIsRefusedAndNothingElseChanges(t *testing.T) {
|
||||
_, _, _, err := fetchAddress(deployOpts{ip: autoIP}, "web05")
|
||||
if err == nil {
|
||||
t.Fatal("an address was fetched with no tool to fetch it from")
|
||||
}
|
||||
for _, want := range []string{"no address tool", "dnstool"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not mention %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A typed address is never handed to a tool, whether there is one or not.
|
||||
h, log := fakeInfoblox(t)
|
||||
opts, rec, give, err := fetchAddress(deployOpts{ip: "10.0.0.55", dns: h}, "web05")
|
||||
if err != nil {
|
||||
t.Fatalf("a typed address went wrong: %v", err)
|
||||
}
|
||||
if opts.ip != "10.0.0.55" || rec.Name != "" {
|
||||
t.Errorf("a typed address was changed to %q", opts.ip)
|
||||
}
|
||||
give() // must be safe, and must ask nothing
|
||||
if calls := asked(t, log); len(calls) != 0 {
|
||||
t.Errorf("the tool was called for an address that was typed: %v", calls)
|
||||
}
|
||||
|
||||
// And no address at all asks nothing either.
|
||||
if _, _, _, err := fetchAddress(deployOpts{dns: h}, "web05"); err != nil {
|
||||
t.Errorf("a deployment with no address at all went wrong: %v", err)
|
||||
}
|
||||
if calls := asked(t, log); len(calls) != 0 {
|
||||
t.Errorf("the tool was called with no address asked for: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Which helper is used: the one named in the configuration, or "dns" on the
|
||||
// path, or none — and one that is named but not there offers nothing rather
|
||||
// than quietly using a different tool of the same name from somewhere else.
|
||||
func TestWhichHelperIsUsed(t *testing.T) {
|
||||
tool, _ := fakeDNS(t, "2.5.0", `echo hello`)
|
||||
|
||||
if got := dnsTool(tool); got.path != tool || !got.there() {
|
||||
t.Errorf("a named helper resolved to %+v", got)
|
||||
}
|
||||
if got := dnsTool(filepath.Join(t.TempDir(), "not-there")); got.there() {
|
||||
t.Errorf("a named helper that is not there resolved to %+v", got)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", filepath.Dir(tool))
|
||||
if got := dnsTool(""); got.path != tool {
|
||||
t.Errorf("the helper on the path resolved to %+v", got)
|
||||
}
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if got := dnsTool(""); got.there() {
|
||||
t.Errorf("a path with no helper on it resolved to %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func run() error {
|
||||
subNew.String(&newStore, "", "datastore", "Put it on this datastore, rather than the template's own")
|
||||
subNew.Bool(&newOn, "", "on", "Power it on once it is made")
|
||||
subNew.String(&newSpec, "", "spec", "Customise the guest with this specification from the vCenter")
|
||||
subNew.String(&newIP, "", "ip", "Give it this address, with the netmask and gateway from the specification")
|
||||
subNew.String(&newIP, "", "ip", "Give it this address — or 'auto' to have the site's dns tool hand one out")
|
||||
subNew.String(&newHostname, "", "hostname", "Name the guest this, rather than after the machine")
|
||||
subNew.Bool(&newSpecs, "", "specs", "List the customisation specifications this vCenter holds")
|
||||
|
||||
@@ -321,9 +321,20 @@ func run() error {
|
||||
if newFrom == "" || newName == "" {
|
||||
return errf("new needs both: gvm new --from <template> --name <machine>")
|
||||
}
|
||||
// The flags are the decision here, so the road is read off them: --spec
|
||||
// names one the vCenter holds, and either of the other two on their own
|
||||
// means gvm writes it from the configuration.
|
||||
how := customNone
|
||||
switch {
|
||||
case newSpec != "":
|
||||
how = customSpec
|
||||
case newIP != "" || newHostname != "":
|
||||
how = customSite
|
||||
}
|
||||
return deployCLI(vc, newFrom, newName, deployOpts{
|
||||
host: newHost, datastore: newStore, powerOn: newOn,
|
||||
spec: newSpec, ip: newIP, hostname: newHostname, st: cfg.site(),
|
||||
host: newHost, datastore: newStore, powerOn: newOn, how: how,
|
||||
spec: newSpec, ip: newIP, hostname: newHostname,
|
||||
st: cfg.site(), dns: dnsTool(cfg.DNSTool),
|
||||
}, yes)
|
||||
|
||||
case subHost.Used:
|
||||
@@ -415,6 +426,18 @@ func showConfig(cfg Config) error {
|
||||
PF("telemetry %s\n", orNone(cfg.Telemetry))
|
||||
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
|
||||
PF("new guest %s\n", cfg.site().describe())
|
||||
// Said whether it is there or not: "why was I not offered an address" is
|
||||
// the question that follows an option quietly not being there, and this is
|
||||
// where it is answered.
|
||||
// The helper, or what is in the way of using it. "There is none" and "the
|
||||
// one you have is too old" send somebody to two different places, and this
|
||||
// is where the difference is worth the line.
|
||||
tool := dnsTool(cfg.DNSTool)
|
||||
said := tool.path + SF(" (v%s)", versionString(tool.version))
|
||||
if !tool.there() {
|
||||
said = tool.why()
|
||||
}
|
||||
PF("dns tool %s\n", said)
|
||||
PF("completion %s\n", inventoryAge())
|
||||
PF("version %s\n", version)
|
||||
return nil
|
||||
|
||||
+13
-1
@@ -85,6 +85,18 @@ telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
|
||||
# netmask = 255.255.255.0
|
||||
# gateway = 10.0.0.1
|
||||
# timezone = Europe/Berlin
|
||||
#
|
||||
# The address can be fetched instead of typed, where the site has a tool for it:
|
||||
#
|
||||
# gvm new --from ubuntu-tpl --name web05 --ip auto
|
||||
#
|
||||
# gvm looks for "dns" on the path and offers that only where it finds one, and
|
||||
# only from v2.5.0 — older ones answer in sentences rather than JSON and are
|
||||
# said to be too old rather than read. Name it here where it lives somewhere
|
||||
# else. It is asked for an address under the
|
||||
# machine's own name, and the address is given back if the deployment does not
|
||||
# happen after all.
|
||||
# dnstool = /usr/local/bin/dns
|
||||
|
||||
# --- ssh, for the sheet's 'h' ---
|
||||
# The command that logs in to a machine's guest from its sheet. "%h" is where
|
||||
@@ -102,7 +114,7 @@ telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
|
||||
#
|
||||
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
|
||||
# GVM_TELEMETRY, GVM_SSH, GVM_DOMAIN, GVM_DNS, GVM_NETMASK, GVM_GATEWAY,
|
||||
# GVM_TIMEZONE
|
||||
# GVM_TIMEZONE, GVM_DNSTOOL
|
||||
# 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,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// Everything else in the interactive half happens because somebody pressed a
|
||||
// key. This is the part that happens because time passed: `^l` turns it on and
|
||||
// the list re-reads itself every few seconds, which turns gvm from something one
|
||||
// looks at into something one leaves open on a second screen.
|
||||
// looks at into something one leaves open on a second screen. Starting a clone
|
||||
// turns it on as well (watchLive), because that is a screen made to be watched
|
||||
// whether or not anybody thought to ask for it first.
|
||||
//
|
||||
// Three things come with it, and they are here rather than in browse.go because
|
||||
// they only mean anything together:
|
||||
@@ -69,6 +71,27 @@ func (b *browser) toggleLive() {
|
||||
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
|
||||
}
|
||||
|
||||
// watchLive turns live mode on because something with a figure to watch was
|
||||
// just set going, and reports whether it had to. ^l is somebody asking for a
|
||||
// screen that keeps itself up to date; this is gvm deciding that a clone it has
|
||||
// just started makes the list one of those, since the progress in the TASK
|
||||
// column is the whole reason to still be looking at it. It stays on afterwards,
|
||||
// like the mode it is — ^l ends it, and the title says it is running.
|
||||
//
|
||||
// Unlike toggleLive it does not sweep at once. The caller has just re-read the
|
||||
// row it acted on, so the first tick belongs one interval away rather than now
|
||||
// — and that interval is already the busy one, because that row is the one
|
||||
// carrying the task.
|
||||
func (b *browser) watchLive() bool {
|
||||
if b.live {
|
||||
return false
|
||||
}
|
||||
b.live = true
|
||||
b.liveGap = b.liveInterval()
|
||||
b.liveNext = time.Now().Add(b.liveGap)
|
||||
return true
|
||||
}
|
||||
|
||||
// liveReady reports whether a tick may happen now. Only the list and a
|
||||
// machine's sheet are refreshed underneath somebody: the menu decides what it
|
||||
// offers from the state it was drawn with, the picker holds a list of snapshots
|
||||
|
||||
@@ -485,3 +485,34 @@ func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
|
||||
t.Errorf("turning it off said %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A clone that has just been started makes the list a screen worth watching, so
|
||||
// live mode turns itself on rather than leaving a percentage that moves only
|
||||
// when somebody remembers ^l. The first tick is an interval away rather than
|
||||
// now — the action re-read its own row a moment ago — and that interval is the
|
||||
// busy one, because the row it re-read is the one carrying the task.
|
||||
func TestStartingAClonePutsTheListOnWatch(t *testing.T) {
|
||||
b := testBrowser("ubuntu-tpl", "web01")
|
||||
b.rows[0].task = &runningTask{what: "clone", progress: 2}
|
||||
|
||||
if !b.watchLive() {
|
||||
t.Fatal("watchLive did not report that it had to turn live mode on")
|
||||
}
|
||||
if !b.live {
|
||||
t.Fatal("live mode is off after a clone was started")
|
||||
}
|
||||
if d := b.liveIn(); d > liveBusy || d < liveBusy/2 {
|
||||
t.Errorf("the first refresh is %s away, want about %s", d, liveBusy)
|
||||
}
|
||||
|
||||
// Already on is not a reason to touch it: a tick that was due in half a
|
||||
// second must not be pushed back to two because a second clone was started.
|
||||
b.liveNext = time.Now().Add(liveBusy / 4)
|
||||
due := b.liveNext
|
||||
if b.watchLive() {
|
||||
t.Error("watchLive reported turning on a mode that was already on")
|
||||
}
|
||||
if !b.liveNext.Equal(due) {
|
||||
t.Errorf("the pending tick moved from %s to %s", due, b.liveNext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,21 @@ func snapNew(vc VCenter, vmname string) error {
|
||||
func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string) error {
|
||||
vm := object.NewVirtualMachine(s.client.Client, ref)
|
||||
|
||||
// Without memory and without quiescing, and both are deliberate.
|
||||
//
|
||||
// Memory would keep the running machine's RAM as well, so that a rollback
|
||||
// came back mid-flight — at the price of writing the whole of it to the
|
||||
// datastore every time, and of a rollback that restores a process tree
|
||||
// along with the disks. What these snapshots are for is the moment before
|
||||
// a patch or an upgrade, where coming back to a machine that boots is the
|
||||
// point and coming back to one that is still half way through the thing
|
||||
// that went wrong is not.
|
||||
//
|
||||
// Quiescing would have VMware Tools still the guest's filesystems first.
|
||||
// Leaving it off makes the disk state crash-consistent — what a machine
|
||||
// would find after the plug was pulled — which a journalling filesystem
|
||||
// handles and a database may not. It also means the snapshot does not
|
||||
// depend on Tools running, and does not stop when they are not.
|
||||
task, err := vm.CreateSnapshot(s.ctx, name, desc, false, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.3.0
|
||||
1.3.9
|
||||
|
||||
Reference in New Issue
Block a user