6 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 94ebe679b7 [mike@mwxm4]
One dialect: -j, and nothing older than v2.5.0 is talked to.

The text road is gone. It said the same things in a sentence and exited 0 while
refusing, and keeping it meant keeping a second way to be wrong for the sake of
versions nobody is running.

An older helper is not silently ignored. "There is none" and "the one you have
is too old" send somebody to two different places, so the helper carries why it
cannot be used and both the refusal and `gvm config` say it:

    /usr/local/bin/dns is 2.4.4, and gvm wants 2.5.0 or newer — 'dns --update' fetches it

A version that cannot be read at all is a third reason, said as itself: gvm
would rather say so than talk to something whose answers it cannot predict.

And the version check found a bug in itself while being tested. The line -v
prints carries two versions —

    dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)

— and taking whichever came first read the toolbox's 0.5.0 the moment the
tool's own could not be read. Which is exactly the quiet wrong answer the check
exists to prevent. It is anchored on the bracket now, and the trap is a test
case of its own.

Checked read-only against the real v2.5.0: read as 2.5.0, usable, and a host
lookup coming back with its address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:30:33 +02:00
Michael WesemannandClaude Opus 5 cc4daece4b [mike@mwxm4]
Ask the dns helper with -j, where it understands it.

From v2.5.0 it answers in an envelope rather than a sentence, and the envelope
is better in every way that matters here: the reason lives in a field of its
own instead of in free text, and the exit status agrees with it at last.

    {"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
    {"ok":false,"action":"showhost","error":"host '...' not found"}

gvm asks the helper its version once, when it finds it, and reads it that way
from then on — a probe per call would be three for one deployment, and a
dialect worked out from the shape of an answer would be a guess made under
pressure. A version that cannot be read is taken for an old one: the older way
works on both, and guessing "new" would leave gvm looking for an envelope that
is not there. `gvm config` says which of the two it found.

Only ok and error are read off the acting calls. What -a carries beyond that is
not needed and therefore not assumed: the address still comes from reading the
record back afterwards, which is the appliance's own answer to the same
question and the thing the machine will actually have.

Everything above the one function that knows about dialects is written once,
against a reply of one shape — that is the whole point of having such a
function rather than two of everything.

Both roads are tested, against a stand-in that speaks either on demand. The
older one is exactly the kind of thing that rots unnoticed once nobody uses it,
and it is the one whose habit of exiting 0 while refusing broke the first
version of this code. Checked read-only against the real v2.5.0 as well: taken
for json, an existing name giving its address, a missing one reading as free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:23:56 +02:00
Michael WesemannandClaude Opus 5 5b23d6b3cd [mike@mwxm4]
Write down why snapshots are taken without memory and without quiescing.

No change in behaviour — Mike confirmed both are what he wants. What was
missing was the reason, on a call whose two false arguments decide what a
rollback gets back, and which nothing in the code or the README explained.

Memory would keep the running machine's RAM as well, so a rollback came back
mid-flight, at the price of writing all of it to the datastore every time.
These snapshots are for the moment before a patch, where coming back to a
machine that boots is the point.

Quiescing would have VMware Tools still the guest's filesystems first. Without
it the disk state is crash-consistent — what a machine finds after the plug is
pulled — which a journalling filesystem handles and a database may not; and the
snapshot neither depends on Tools running nor stops when they are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:14:53 +02:00
Michael WesemannandClaude Opus 5 b7252d7a6d [mike@mwxm4]
The name has to be free, the address is shown — and the helper is read
properly, which is what the first version got wrong.

Measured rather than assumed this time: `dns -s` on a name that is not there
prints "ERROR: host '...' not found" and exits 0. So the helper says what is
wrong in its output, not in its status, and gvm now reads it that way. The
first version believed the status, which took every refusal for an agreement
and — the other way about — made a free name look like an answer nobody could
parse. 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.

A name the appliance already knows is refused before anything is added to it.
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'

The address is said where it stays. In the confirmation, as before, and now
also in the line left on the screen afterwards and in what `gvm new` prints,
because it is a number somebody writes down:

    web05 is being made at 141.14.140.182 — ubuntu-tpl shows how far along it is

The stand-in the tests run against has the real tool's two habits now: errors
in the output, beginning with ERROR, and exit 0 while saying so. The old
stand-in answered in exit statuses, which is exactly why it let the broken
version pass. Checked the other way as well — with the status-believing code
put back, the tests fail on "answered something that is not a host record".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:42:14 +02:00
Michael WesemannandClaude Opus 5 da56a5d048 [mike@mwxm4]
An address fetched rather than looked up: `gvm new ... --ip auto`, and "auto"
typed at the same question in the list.

dns.go shells out to the site's Infoblox helper — found on the path as "dns",
or named as dnstool in ~/.gvmrc. Everything that tool knows stays its own: the
appliance, the credentials, the default domain. A second copy of any of it
inside gvm would be a second thing to keep right.

It is only offered where such a tool is there. `gvm config` now says whether
one was found, because "why was I not offered that" is the question that
follows an option quietly not being there — the same lesson as the
customisation picker last week.

Three things are deliberate, and each is a rule about acting on something
outside gvm:

* The record is read back rather than taken from the sentence the helper prints
  on its way past. "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, and where the two disagree the record is what the
  machine will actually have.
* It is fetched before the confirmation, so the question says the address
  rather than a promise of one.
* And given back where it is not used. A deployment abandoned at that question,
  or one the vCenter then refuses, releases it rather than leaving a record for
  a machine that was never made. That is what deployStep carries — the note,
  the record and the way to give it back travelling together, because a
  confirmation with eight arguments is one nobody can call correctly.

The name asked for is the machine's own short name — the helper puts the record
in the site's default domain itself — which is also the hostname the guest gets,
so the two cannot disagree.

Every call passes -y: the helper asks before it changes anything when it has a
terminal, and gvm has already asked.

Tested against a stand-in written by the test, never the real tool: `dns -a`
takes an address out of the institute's Infoblox and `dns -d` gives one back,
and a suite that did either would be editing the network every time it ran.
What is checked is everything around the call — what is asked for, what is made
of the answer, and what happens when the answer is no.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:46:13 +02:00
Michael WesemannandClaude Opus 5 2da48d9b25 [mike@mwxm4]
"address for web05 (empty = as  says):" — a sentence with a hole in it.

The hole is where the name of a specification nobody had chosen would have
gone, and it was there because the road was being worked out from the other
fields rather than carried. The interactive half settles which road before it
asks for an address, so at that moment spec, ip and hostname are all still
empty — which read as "no customisation at all", took the branch written for a
vCenter specification, and printed its name, which was "".

So the road is a decision now and not an inference: customNone, customSite,
customSpec, set where it is made. The command line reads it off the flags,
which is where the decision genuinely is there; the list sets it in the picker,
before anything has been typed.

The question is also plainer than it was. "(empty = as linux-static says)"
reads as a sentence with a word missing even when the name is in it:

    address for web05, or empty for DHCP:
    address for web05, or empty to leave it to linux-static:

The test drives the real question on both roads and looks for the hole. It was
checked against the old code first, which produced Mike's line exactly:
"address for web05 (empty = as  says):".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 17:02:06 +02:00
12 changed files with 1128 additions and 51 deletions
+104 -2
View File
@@ -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 |
@@ -443,6 +444,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
@@ -528,8 +539,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 +555,81 @@ 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:
address for web05, "auto" for one from dns, empty for DHCP:
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 — 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 +1121,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 —
+51 -17
View File
@@ -657,7 +657,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 +667,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 +688,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})
}
})
}
@@ -705,20 +707,41 @@ func (b *browser) deploy(r vmRow) {
// usually DHCP, and that is the ordinary case on a network that hands out
// addresses.
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
}
ask := SF("address for %s, %s: ", name, leave)
if opts.dns.there() {
ask = SF("address for %s, %q for one from dns, %s: ", name, autoIP, leave)
}
ip, ok := b.input(ask)
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 +749,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 +772,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 +784,24 @@ 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))
// 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 shows how far along it is", name, at, src.name))
}
// working puts a line on the screen before an operation that will block the loop
+5 -3
View File
@@ -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))
+9 -1
View File
@@ -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")
+110 -22
View File
@@ -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
}
+90 -1
View File
@@ -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,92 @@ 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")
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"},
} {
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")
}
}
+347
View File
@@ -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
View File
@@ -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)
}
}
+26 -3
View File
@@ -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
View File
@@ -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,
+15
View File
@@ -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
View File
@@ -1 +1 @@
1.3.0
1.3.7