2 Commits
Author SHA1 Message Date
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
11 changed files with 738 additions and 23 deletions
+74
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 |
@@ -544,6 +545,68 @@ 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:
* **What it says is believed before how it exits.** `dns -s` on a name that is
not there prints `ERROR: host '...' not found` and exits 0 all the same, so a
refusal is read out of the output. gvm's first version read the exit 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.
* **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
@@ -1035,6 +1098,17 @@ 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 the helper's habits are the real ones — the stand-in says what
is wrong in its output and exits 0 while doing so, which is what the first
version of this got wrong
* 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 —
+43 -13
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,12 +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{how: customSite, 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{how: customSpec, spec: kinds[i], st: b.site})
deployOpts{how: customSpec, spec: kinds[i], st: b.site, dns: b.dns})
}
})
}
@@ -712,17 +713,35 @@ func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name
// 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.
ask := SF("address for %s, or empty for DHCP: ", name)
//
// 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 {
ask = SF("address for %s, or empty to leave it to %s: ", name, opts.spec)
leave = "empty to leave it to " + opts.spec
}
ask := SF("address for %s, %s: ", name, leave)
if opts.dns != "" {
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.
@@ -730,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")
@@ -752,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) {
@@ -761,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
+3 -1
View File
@@ -761,6 +761,7 @@ type browser struct {
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 string // the site's address helper, or "" where this machine has none
// 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))
+8 -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,11 @@ 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 offers the option only where it finds it.\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")
+82 -3
View File
@@ -54,8 +54,14 @@ type deployOpts struct {
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
ip string // empty leaves the adapter on DHCP; autoIP fetches one
st site // the site's own answers, from the configuration
dns string // the site's address helper, or "" where there is none
}
// 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
@@ -309,6 +315,55 @@ 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 == "" {
return opts, dnsHost{}, nothing, errf("there is no address tool on this machine — put one "+
"on the path as 'dns', name it as dnstool in %s, or give --ip an address", configFile())
}
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
@@ -661,12 +716,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
@@ -674,7 +743,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)
}
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))
@@ -686,17 +759,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
}
+1 -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()
+211
View File
@@ -0,0 +1,211 @@
// 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"
"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"
// dnsTool is the helper to use, or "" where this machine has none.
//
// 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.
func dnsTool(configured string) string {
if c := strings.TrimSpace(configured); c != "" {
if path, err := exec.LookPath(c); err == nil {
return path
}
return "" // named but not there: the option is not offered, and says so
}
path, err := exec.LookPath("dns")
if err != nil {
return ""
}
return path
}
// 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
}
// dnsRun is one call to the helper. -y throughout: it asks before it changes
// anything when it has a terminal, and gvm has already asked.
//
// The output is believed before the exit status, because that is how this tool
// reports: `dns -s` on a name that is not there prints "ERROR: host '...' not
// found" and exits 0 all the same. Reading only the status took every refusal
// for an agreement — and, the other way round, made a name that was free look
// like an answer nobody could parse.
func dnsRun(tool string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
defer cancel()
out, err := exec.CommandContext(ctx, tool, append(args, "-y")...).CombinedOutput()
text := strings.TrimSpace(string(out))
switch {
case dnsSaidError(text):
// The helper's own words, which say what is wrong far better than any
// status: "host already exists", "no free address in the network".
return text, errf("%s", firstLine(text))
case err != nil:
if text == "" {
return "", errf("%s %s: %w", tool, strings.Join(args, " "), err)
}
return text, errf("%s", firstLine(text))
}
return text, nil
}
// dnsSaidError and dnsNotFound read the two things the helper says about
// itself. "not found" is an answer rather than a failure — it is what a free
// name looks like — and everything else beginning with ERROR is a refusal.
func dnsSaidError(out string) bool {
return strings.HasPrefix(strings.TrimSpace(out), "ERROR")
}
func dnsNotFound(out string) bool {
return dnsSaidError(out) && strings.Contains(out, "not found")
}
// 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(tool, host string) (dnsHost, error) {
out, err := dnsRun(tool, "-s", dnsName(host))
if err != nil {
if dnsNotFound(out) {
return dnsHost{}, nil
}
return dnsHost{}, err
}
var rec dnsHost
if err := json.Unmarshal([]byte(out), &rec); err != nil {
return dnsHost{}, errf("%s answered something that is not a host record: %s", tool, firstLine(out))
}
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(tool, 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(tool, 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)
}
said, err := dnsRun(tool, "-a", name)
if err != nil {
return dnsHost{}, err
}
rec, err := dnsShow(tool, name)
if err != nil {
return dnsHost{}, err
}
if rec.address() == "" {
return dnsHost{}, errf("%s said %q, but the record cannot be read back", tool, firstLine(said))
}
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(tool, host string) error {
_, err := dnsRun(tool, "-d", dnsName(host))
return err
}
// 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
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fakeDNS writes a stand-in for the site's helper and returns it with the file
// it writes down what it was asked in.
//
// Nothing in this file may touch the real one. `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 the thing 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.
//
// It keeps the two habits of the real tool that gvm has to live with, both
// measured rather than assumed: it says what is wrong in its output, beginning
// with ERROR, and it exits 0 while doing so — a name that is not there is
// "ERROR: host '...' not found" and a successful run at the same time. A
// stand-in that answered in exit statuses would have let gvm's first version
// pass, which believed them.
func fakeDNS(t *testing.T, body string) (tool, log string) {
t.Helper()
dir := t.TempDir()
tool = filepath.Join(dir, "dns")
log = filepath.Join(dir, "asked")
script := "#!/bin/sh\necho \"$@\" >> " + log + "\n" + body + "\nexit 0\n"
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) (tool, log string) {
t.Helper()
state := filepath.Join(t.TempDir(), "records")
return fakeDNS(t, `
state=`+state+`
case "$1" in
-a) if grep -qx "$2" $state 2>/dev/null; then
echo "ERROR: host '$2.fhi.mpg.de' already exists"
else
echo "$2" >> $state
echo "OK: host '$2.fhi.mpg.de' added with IP '141.14.140.182'"
fi ;;
-s) if grep -qx "$2" $state 2>/dev/null; then
printf '{"name":"%s.fhi.mpg.de","ipv4addrs":[{"ipv4addr":"141.14.140.182"}]}\n' "$2"
else
echo "ERROR: host '$2.fhi.mpg.de' not found"
fi ;;
-d) grep -vx "$2" $state > $state.tmp 2>/dev/null
mv $state.tmp $state 2>/dev/null
echo "OK: host '$2.fhi.mpg.de' deleted" ;;
esac`)
}
// asked is what the helper was called with, one call per line.
func asked(t *testing.T, log string) []string {
t.Helper()
out, err := os.ReadFile(log)
if err != nil {
return nil
}
return strings.Split(strings.TrimSpace(string(out)), "\n")
}
// The record is read back rather than taken from the sentence the helper prints
// on its way past: "OK: host '...' added with IP '...'" is written for a person
// and gets reworded between versions, while the record is the appliance's own
// answer to the same question.
func TestAnAddressIsReadBackFromTheRecord(t *testing.T) {
tool, log := fakeInfoblox(t)
rec, err := dnsAdd(tool, "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 -y, which 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.Errorf("it was asked %v", calls)
}
for _, c := range calls {
if !strings.Contains(c, "-y") {
t.Errorf("a call was made that could stop and ask: %q", c)
}
}
}
// A helper that refuses says why in its own words, which are better than any
// gvm could put there: "host already exists", "no free address in the network".
func TestTheHelpersOwnWordsComeBack(t *testing.T) {
tool, _ := fakeDNS(t, `echo "ERROR: no free address in the network 141.14.128.0/20"`)
_, err := dnsAdd(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)
}
}
// A name that is already in the appliance is refused before anything is added
// to it — and the refusal says what is in the way and how to clear it. What
// `dns -a` would do with it is either refuse in worse words, or hang a second
// address on somebody else's host record.
func TestANameThatIsTakenIsRefused(t *testing.T) {
tool, log := fakeInfoblox(t)
if _, err := dnsAdd(tool, "web05"); err != nil {
t.Fatalf("the first one went wrong: %v", err)
}
before := len(asked(t, log))
_, err := dnsAdd(tool, "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)
}
}
// Refused before anything was asked of it beyond the question.
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:])
}
}
// A helper that cannot be reached is not a free name. Swallowing that would
// turn an appliance that is down into "nobody has this name", which is the last
// thing to conclude before asking it for an address.
func TestAnApplianceThatIsDownIsNotAFreeName(t *testing.T) {
tool, _ := fakeDNS(t, `echo "ERROR: cannot reach infoblox.fhi.mpg.de"`)
if _, err := dnsShow(tool, "web05"); err == nil {
t.Error("an appliance that could not be reached reported a free name")
}
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Error("an address was asked for over an appliance that could not be reached")
}
}
// A helper that agrees but leaves no record is not an answer either: a
// deployment would then be given an empty address and the machine would come up
// on nothing.
func TestAnAddressThatCannotBeReadBackIsNoAddress(t *testing.T) {
tool, _ := fakeDNS(t, `case "$1" in
-a) echo "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'" ;;
-s) echo '{"name": "web05.fhi.mpg.de", "ipv4addrs": []}' ;;
esac`)
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Fatal("an empty record was taken for an address")
}
// And something that is not a record at all.
tool, _ = fakeDNS(t, `case "$1" in
-a) echo "OK" ;;
-s) echo 'not json at all' ;;
esac`)
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Fatal("a page of prose was taken for a host record")
}
}
// 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
// somebody abandons at the confirmation must not leave a record behind for a
// machine that was never made.
func TestAFetchedAddressIsGivenBack(t *testing.T) {
tool, log := fakeInfoblox(t)
opts := deployOpts{how: customSite, st: testSite(), ip: autoIP, dns: tool}
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()
calls := asked(t, log)
if n := len(calls); n == 0 || !strings.HasPrefix(calls[n-1], "-d web05") {
t.Errorf("the address was not given back: %v", calls)
}
// And having given it back, the name is free again — which is the whole
// point of giving it back.
if rec, err := dnsShow(tool, "web05"); err != nil || rec.Name != "" {
t.Errorf("the name is still taken after being released: %+v (%v)", rec, err)
}
}
// Everything above happens only where the site has such a tool. Without one,
// "auto" is refused with what to do about it, and — the point of the whole
// arrangement — 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.
tool, log := fakeDNS(t, `echo "this should never run"; exit 1`)
opts, rec, give, err := fetchAddress(deployOpts{ip: "10.0.0.55", dns: tool}, "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: tool}, "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 to use: the one named in the configuration, or "dns" on the
// path, or none — and a configuration that names one that is not there offers
// nothing rather than quietly using a different one.
func TestWhichHelperIsUsed(t *testing.T) {
tool, _ := fakeDNS(t, `echo hello`)
if got := dnsTool(tool); got != tool {
t.Errorf("a named helper resolved to %q", got)
}
if got := dnsTool(filepath.Join(t.TempDir(), "not-there")); got != "" {
t.Errorf("a named helper that is not there resolved to %q", got)
}
// Found by name on the path, and not found where it is not.
t.Setenv("PATH", filepath.Dir(tool))
if got := dnsTool(""); got != tool {
t.Errorf("the helper on the path resolved to %q", got)
}
t.Setenv("PATH", t.TempDir())
if got := dnsTool(""); got != "" {
t.Errorf("a path with no helper on it resolved to %q", got)
}
}
+11 -2
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")
@@ -333,7 +333,8 @@ func run() error {
}
return deployCLI(vc, newFrom, newName, deployOpts{
host: newHost, datastore: newStore, powerOn: newOn, how: how,
spec: newSpec, ip: newIP, hostname: newHostname, st: cfg.site(),
spec: newSpec, ip: newIP, hostname: newHostname,
st: cfg.site(), dns: dnsTool(cfg.DNSTool),
}, yes)
case subHost.Used:
@@ -425,6 +426,14 @@ 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.
tool := dnsTool(cfg.DNSTool)
if tool == "" {
tool = "none found — 'gvm new --ip auto' wants one, as 'dns' on the path or dnstool here"
}
PF("dns tool %s\n", tool)
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
+11 -1
View File
@@ -85,6 +85,16 @@ 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; 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 +112,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,
+1 -1
View File
@@ -1 +1 @@
1.3.1
1.3.3