1 Commits
Author SHA1 Message Date
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
6 changed files with 143 additions and 29 deletions
+5 -2
View File
@@ -528,8 +528,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
+12 -8
View File
@@ -690,9 +690,10 @@ func (b *browser) deploy(r vmRow) {
case "":
b.deployAsk(r, src, target, name, deployOpts{}, "")
case "-":
b.deployAddress(r, src, target, name, deployOpts{st: b.site})
b.deployAddress(r, src, target, name, deployOpts{how: customSite, st: b.site})
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})
}
})
}
@@ -705,12 +706,15 @@ 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.
ask := SF("address for %s, or empty for DHCP: ", name)
if opts.how == customSpec {
ask = SF("address for %s, or empty to leave it to %s: ", name, opts.spec)
}
ip, ok := b.input(ask)
if !ok {
+25 -17
View File
@@ -51,32 +51,40 @@ 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
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
st site // the site's own answers, from the configuration
}
// 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 strings.TrimSpace(o.spec) != "" || strings.TrimSpace(o.ip) != "" ||
strings.TrimSpace(o.hostname) != ""
}
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
+89
View File
@@ -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")
}
}
+11 -1
View File
@@ -321,8 +321,18 @@ 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,
host: newHost, datastore: newStore, powerOn: newOn, how: how,
spec: newSpec, ip: newIP, hostname: newHostname, st: cfg.site(),
}, yes)
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.3.1