6 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
Michael WesemannandClaude Opus 5 5d90d6ef8a [mike@mwxm4]
Version 1.3.0.

A minor step, asked for by hand: build.sh only ever bumps the last number, so
this is the one kind of version change that is a decision rather than
bookkeeping. `var version` in gvm.go tracks the MAJOR.MINOR line and follows it
— a test compares the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:53:47 +02:00
Michael WesemannandClaude Opus 5 2babb72e62 [mike@mwxm4]
The second road: gvm writes the customisation itself, out of ~/.gvmrc.

Five settings, which are the site's answers and not any machine's — domain,
dns, netmask, gateway, timezone — written once in the configuration file where
every other site answer already lives. The two that are about the one machine
stay on the command line:

    gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55

--spec stays exactly as it was, and both roads are now offered side by side:
the picker lists gvm's own alongside whatever specifications the vCenter holds,
and the deployment takes whichever was chosen. A site with specifications
should still prefer them — the policy is then somewhere the web client can see
it too — and Windows has no other option, since a Sysprep is a licence key, an
administrator password and a domain to join, none of which belongs in a file
next to the SMTP relay. A Windows template is refused by name, pointing at
--spec.

What it writes: LinuxPrep with the hostname (the machine's name unless
--hostname says otherwise), the domain and the timezone; the resolvers and the
search domain in the global settings; and one adapter with the address, the
netmask and the gateway. Without --ip the adapter is left on DHCP, which is a
whole answer — the name is still set, and that is what was asked for.

Refused rather than guessed: a configuration that is not complete, named field
by field; an address, netmask or gateway that is not one, each said to be the
configuration's; and a Windows template. Said rather than refused: a gateway
that is not on the machine's own network. A routed setup can put one anywhere,
so it is not an error — but almost every time it is a typo, and a machine that
cannot reach its gateway is one somebody drives to the console for.

`gvm config` grew a line for it, which says either what a new guest would be
told or which settings are still missing. browseVMs takes the configuration
rather than one string out of it, since it now needs two things from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:43:47 +02:00
Michael WesemannandClaude Opus 5 badd357ca5 [mike@mwxm4]
The customisation picker skipped itself in silence, which looks exactly like a
step that is broken.

Two ways to get there, and both were quiet: a vCenter that holds no
customisation specifications — where not asking is right, since the answer
could only be "none" — and one that will not let them be read, where the
warning that was set went straight into the line the next question draws over
and was never seen.

Both now say so on the confirmation, and the reason goes *first* on that line:
it shares one line with the placement, a terminal cuts from the right, and the
placement is the same every time while "there was no question about
customisation, and here is why" is what somebody is looking for.

    make web05 from ubuntu-tpl on v308?   y = yes, anything else = no
    v308 has no customisation specifications · in prod-cluster, powered off

`gvm new --specs` lists what a server holds, which is the question that follows
the one above, and prints the server's own words where it refuses.

The mechanism itself was right: driven end to end against the simulator — a
template, a typed name, and the picker on the screen with its four
specifications. What was missing was everything gvm says when there are none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:24:45 +02:00
Michael WesemannandClaude Opus 5 ab76d56858 [mike@mwxm4]
Hostname and address on a deployed machine, out of the vCenter's own
customisation specifications.

--spec names one the vCenter already holds — the ones made in the vSphere
client under Policies and Profiles. Everything about the site lives there: the
domain, the DNS servers, the netmask, the gateway, the timezone, Linux or
Windows. gvm writes exactly two things into it, the two that are about this one
machine: the hostname, which defaults to the machine's own name, and the
address. A tool that knew better than the vCenter about any of the rest is how
one ends up with a network policy nobody remembers agreeing to.

    gvm new --from ubuntu-tpl --name web05 --spec linux-static --ip 10.0.0.55

In the list it is a step of its own: after the name, a picker of the
specifications the server holds with "leave the guest as the template made it"
at the top, then one line for the address — empty leaves what the specification
says.

Refused rather than guessed at:

* An address where the specification's adapter has no netmask, because it takes
  one from DHCP. An address without a mask half works, and where to get a mask
  is a question for the vCenter.
* A hand-written Windows answer file, and a cloud-init specification: both
  carry the hostname in a format gvm does not own.
* Silence about VMware Tools. A template that reports none cannot have a
  customisation carried out inside it, and that is said before the clone rather
  than found twenty minutes later on a machine that came up under the
  template's name. It is a guess — the version is what the machine last
  reported — and it is said as one; the machine is made either way.

The confirmation reads the two facts back off the specification that was built
rather than repeating what was asked for, so what it shows is what will happen.

The snapshot picker is now a picker: a title, lines, and what to do with the
one that is chosen. The second list — these specifications — would otherwise
have been a near-copy of the screen and its keys, and two of those drift.

Tested against the simulator, which holds four real specifications covering
every shape that matters: LinuxPrep with a netmask, LinuxPrep on DHCP without
one, and two Sysprep ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:15:22 +02:00
Michael WesemannandClaude Opus 5 4ed9d53602 [mike@mwxm4]
Making a machine from a template: p in a template's action menu, `gvm new` on
the command line.

deploy.go is the only thing in gvm that brings a machine into being rather than
acting on one that exists, and that makes its hard question where rather than
whether. A template has no resource pool — vSphere takes it away when a machine
is marked as one — so a copy of it has nowhere to run until something says
where. That is the one thing it cannot inherit; the folder, the datastore and
the hardware it can.

So the placement is worked out before anything is asked and the confirmation
says it in full: the pool is the one the template's own host belongs to, which
on a cluster is the cluster's and leaves the host to DRS the way every other
deployment there does. --host pins it, --datastore moves it.

The interactive half does not wait. A clone is minutes to the half hour, and a
list frozen for that long is a list nobody would start one from. vCenter hangs
the task off the template, so the row it was started from shows the progress in
its TASK column — which is what live mode was for — and the new machine turns
up in the list when it exists, announced on the changed line. `gvm new` does
wait: a script that gets its prompt back wants the machine to be there.

A template's menu is its own: the one thing that can be done with it at the
top, and everything else greyed with "a template" beside it, because vSphere
will not start one, snapshot one or reconfigure one. Greyed rather than left
out — a menu that changes shape between rows is one nobody learns.

Refused before anything is sent: a source that is not a template, a name
vSphere would not take, and a name the server already has (which vCenter itself
would only refuse several seconds into the clone).

No guest customisation — no hostname, no address, no domain join. That is a
second machine's worth of vSphere, it is site policy rather than a tool's
business, and a half-done version of it would be worse than none.

confirmDestructive gains a sibling without the warning, and both now print
their fact block from one place.

Tested against the simulator end to end: an ordinary machine refused, the same
machine marked as a template and deployed from, and what comes out read back
off the server — a machine and not another template, in the pool it was given,
switched off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:44:37 +02:00
12 changed files with 2183 additions and 57 deletions
+153
View File
@@ -44,6 +44,13 @@ setting has an environment spelling that wins over the file — `GVM_VCENTER_V30
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
without the password living in a file.
Besides the servers it holds the site's own answers, each written once: where
mail goes, where telemetry is posted, how `h` logs in to a guest, and what a
machine made from a template is told about the network (`domain`, `dns`,
`netmask`, `gateway`, `timezone` — see *Making a machine from a template*).
`gvm config` prints the lot, passwords as set or not set, and names what is
missing for anything that is half configured.
The file holds passwords, so gvm creates it mode 0600 and complains when it
finds it readable by others.
@@ -99,6 +106,11 @@ not.
| `power --reset <vm>` | reset at the hypervisor — hard |
| `size --vm <vm>` | what it has: vCPUs, memory, and what may be changed while it runs |
| `size --vm <vm> -c 8 -m 16` | give it 8 vCPUs and 16 GB — `-m 512m` for megabytes |
| `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 --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 |
| `host -c` | just the machine counts |
| `ds [-t]` | per-datastore capacity, free space, over-commitment; `-t` also posts them |
@@ -431,6 +443,130 @@ 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.
### Making a machine from a template
`p` in a template's action menu, `gvm new` on the command line. It is the one
thing gvm does that brings a machine into being rather than acting on one that
already exists, and that makes its hard question *where* rather than *whether*.
A template is not a machine that happens to be switched off. vSphere takes its
resource pool away when it is marked as one, so a copy of it has nowhere to run
until somebody says where — and that is the one thing that cannot be inherited
from the source. Everything else is: the folder it sits in, the datastore it
lives on, the hardware it was built with.
So the placement is worked out first and the question says it in full:
make web05 from ubuntu-24.04-template on v308? y = yes, anything else = no
it will run in prod-cluster, powered off
The resource pool is the one the template's own host belongs to — the cluster's,
where it is in a cluster, which leaves the choice of host to DRS the way every
other deployment there does. `--host` overrides that and pins it; `--datastore`
puts it somewhere other than the template's own.
**It does not wait.** A clone copies every disk the template has, which is
minutes to the half hour, and a list frozen for that long is a list nobody would
start one from. The task hangs off the template in vCenter, so the row it was
started from shows how far along it is in its TASK column — `^l` makes that move
on its own, and announces the new machine on the line under the table when it
exists. `gvm new` on the command line does wait: a script that gets its prompt
back wants the machine to be there.
The rest of a template's menu is greyed out with "a template" next to it, because
vSphere will not start one, snapshot one or reconfigure one. They are greyed
rather than left out: a menu that changes shape between rows is one nobody
learns.
#### Telling the guest what it is
A copy of a template comes up as the template: same hostname, same address. Those
two are the facts that are about *this* machine, and everything else — the
domain, the resolvers, the netmask, the gateway, the timezone — is the site's
answer, the same for every machine. So the two are typed and the rest is kept
somewhere it is written once.
**Two places it can be kept, and gvm takes it from either.**
*In `~/.gvmrc`*, which is the short road for a site that keeps no specifications
in its vCenter:
domain = fhi-berlin.mpg.de
dns = 141.14.128.1, 141.14.128.2
netmask = 255.255.255.0
gateway = 10.0.0.1
timezone = Europe/Berlin
gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55
gvm writes the customisation itself from those: hostname from the machine's name
(`--hostname` where they differ), address from `--ip`, and the five above. It is
**Linux only** — a Windows guest is a Sysprep, which is a licence key, an
administrator password and a domain to join, and none of that belongs in a
configuration file next to the SMTP relay. `gvm config` shows what it would
write, and names the settings that are still missing.
*In the vCenter*, as a customisation specification made in the vSphere client
under *Policies and Profiles*:
gvm new --from ubuntu-tpl --name web05 --spec linux-static --ip 10.0.0.55
`--spec` names one, and **gvm overrides exactly two things in it** — the
hostname and the address — and leaves the rest alone. This is the road for
Windows, and the one to prefer where the specifications already exist: the
policy is then where the web client and every other tool can see it too. A tool
that knew better than the vCenter about any of the rest is how one ends up with
a network policy nobody remembers agreeing to.
In the list it is a step of its own: after the name, a picker of whichever roads
are open —
customise web05 how v308
▸ leave the guest as the template made it
this site — fhi-berlin.mpg.de, gateway 10.0.0.1
linux-static
windows-domain
— 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
no specifications (or will not say) *and* `~/.gvmrc` has not been told the site:
make web05 from ubuntu-tpl on v308? y = yes, anything else = no
no specifications on v308, and ~/.gvmrc has no domain, netmask, gateway · in prod-cluster, powered off
`gvm new --specs` lists what a server holds, and prints the server's own words
when it will not say.
Three things it refuses rather than guesses at, on either road:
* **An address with no netmask to go with it.** Where the specification's
adapter takes its address from DHCP it carries no mask, and a machine given an
address without one half works. Where to get a mask from is a question for the
vCenter.
* **A hand-written Windows answer file**, and a cloud-init specification. Both
carry the hostname inside a format gvm does not own, and reaching into either
to change one line would be guessing.
* **Nothing at all**, silently. If the template reports no VMware Tools — which
is what carries a customisation out inside the guest — it says so before the
clone rather than leaving it to be discovered twenty minutes later on a
machine that came up under the template's name. It is a guess and is said as
one: the machine is made either way.
One more thing it says rather than refuses: a gateway that is not on the
machine's own network. A routed setup can put one anywhere, so it is not an
error — but almost every time it is a typo, and a machine that cannot reach its
gateway is one somebody drives to the console for.
Deploying without either road still works, and the guest comes up as the
template made it.
### The sheet's four letters
The four letters are the things that change nothing, on the machine or on the
@@ -895,6 +1031,23 @@ wrong quietly:
* that `y` names what it copied and that an ssh login uses no local clipboard
tool — the tests say they are a login, which also keeps them off the clipboard
of whoever is running them
* that a step which does not happen says why — a vCenter with no customisation
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 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 —
named field by field
* that a customisation writes the hostname and the address into a vCenter's own
specification and changes nothing else in it, for Linux and for Windows; that
an address with no netmask behind it is refused, as are the two specification
kinds gvm will not reach into; and — against the simulator's four real
specifications — that the whole way through works
* that a deployment is refused from anything that is not a template, that a name
already in use is refused before the clone starts, and — against the simulator
— that what comes out is a machine rather than another template, in the pool it
was given, switched off
* every rule a resize is held to: what a running machine may and may not be
given, that the cores per socket are never changed to make a vCPU count fit,
that a refusal never names 0 vCPUs as a count that would, and — against the
+268 -33
View File
@@ -183,8 +183,35 @@ func (b *browser) closeMenu() {
b.menu, b.menuSnaps, b.menuInfo, b.menuSel = nil, nil, nil, 0
}
// buildMenu is the menu for one machine in its current state.
// buildMenu is the menu for one machine in its current state — or for a
// template, which is a different thing with a different menu.
func (b *browser) buildMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
if r.isTemplate() {
// vSphere refuses to start a template, to snapshot it or to reconfigure
// it — the one thing to be done with one is to make a machine from it.
// So the entry that does that is put at the top, where the eye starts,
// and everything else says what is in the way rather than being left
// out: a menu that changes shape between machines is one nobody learns.
why := SF("%s is a template — make a machine from it first", r.name)
items := []menuItem{
{key: 'p', label: "deploy a new machine from this template ...",
run: func(b *browser, r vmRow) { b.deploy(r) }},
separator(),
}
for _, m := range b.machineMenu(r, snaps, sz) {
if !m.isSeparator() {
m.why, m.hint = why, "a template"
}
items = append(items, m)
}
return items
}
return b.machineMenu(r, snaps, sz)
}
// machineMenu is everything one does to a machine that exists: its snapshots,
// what it is made of, and its power.
func (b *browser) machineMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
noSnaps, noSnapsHint := "", ""
if len(snaps) == 0 {
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
@@ -288,10 +315,27 @@ func (b *browser) menuMove(step int) {
}
}
// ----------------------------------------------------------- the snapshot picker
// ------------------------------------------------------------------ the picker
// pickKind is what the picker is picking for. It decides the wording and what
// happens to the snapshot that is chosen.
// picker is one list of things to choose one of. It knows nothing about what it
// is choosing: the caller hands it the lines to draw and what to do with the
// one that is picked.
//
// It was the snapshot picker and nothing else. Making it general rather than
// copying it for the second list — the customisation specifications a
// deployment can use — is the difference between one screen with one set of
// keys and two screens that drift apart.
type picker struct {
title string // "revert to which snapshot of web01"
row vmRow
lines []string
chosen func(int) // what Enter does with the index
sel int
scroll int
}
// pickKind is what a snapshot picker is picking for: it decides the wording and
// what happens to the snapshot that is chosen.
type pickKind int
const (
@@ -299,20 +343,36 @@ const (
pickRemove
)
type picker struct {
kind pickKind
row vmRow
items []snapEntry
sel int
scroll int
}
func (b *browser) openPicker(r vmRow, kind pickKind) {
if len(b.menuSnaps) == 0 {
b.setStatus(colWarn, r.name+" has no snapshots")
return
}
b.pick = &picker{kind: kind, row: r, items: b.menuSnaps}
snaps := b.menuSnaps
title := "revert to which snapshot"
if kind == pickRemove {
title = "remove which snapshot"
}
lines := make([]string, len(snaps))
for i, e := range snaps {
lines[i] = e.line()
}
b.choose(title+" of "+r.name, r, lines, func(i int) {
b.closeMenu()
if kind == pickRevert {
b.revert(r, snaps[i])
return
}
b.removeOne(r, snaps[i])
})
}
// choose puts a list on the screen and calls back with what was picked. The
// callback runs after the picker has closed, so that what it does — a
// confirmation, another question — has the screen to itself.
func (b *browser) choose(title string, r vmRow, lines []string, chosen func(int)) {
b.pick = &picker{title: title, row: r, lines: lines, chosen: chosen}
}
func (b *browser) closePicker() { b.pick = nil }
@@ -325,24 +385,18 @@ func (b *browser) pickerKey(k key) {
case keyUp, keyShiftTab:
p.sel = max(p.sel-1, 0)
case keyDown, keyTab:
p.sel = min(p.sel+1, len(p.items)-1)
p.sel = min(p.sel+1, len(p.lines)-1)
case keyHome:
p.sel = 0
case keyEnd:
p.sel = len(p.items) - 1
p.sel = len(p.lines) - 1
case keyEnter:
if p.sel < 0 || p.sel >= len(p.items) {
if p.sel < 0 || p.sel >= len(p.lines) {
return
}
entry := p.items[p.sel]
kind, row := p.kind, p.row
at, chosen := p.sel, p.chosen
b.closePicker()
b.closeMenu()
if kind == pickRevert {
b.revert(row, entry)
return
}
b.removeOne(row, entry)
chosen(at)
}
}
@@ -534,6 +588,192 @@ func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
return SF("memory for %s in GB (now %s, or 512m): ", r.name, k.shown(sz.memoryMB))
}
// deploy makes a new machine from the template under the cursor.
//
// It does not wait. A clone copies every disk the template has and takes
// minutes to the half hour, and a list that is frozen for that long is a list
// nobody will start one from. vCenter carries the task either way, and it hangs
// off the template — so the row this was started from shows how far along it is
// in its TASK column, and ^l makes that move on its own. The machine itself
// turns up in the list when it exists, which live mode announces.
//
// The question at the end is the plain one. Nothing is lost here; something is
// made, and what has to be read before it is made is *where* — which is why
// that goes on the line under the question rather than into it, where the width
// would eat it.
func (b *browser) deploy(r vmRow) {
b.closeMenu()
if r.sess == nil {
b.setStatus(colErr, "no connection to "+r.vc.Name)
return
}
// Read again rather than taken from the row: whether this is a template is
// the whole premise, and the folder it sits in is not in the sweep.
src, err := sourceOf(r.sess, r.ref)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
if !src.template {
b.setStatus(colWarn, SF("%s is a machine, not a template", src.name))
return
}
target, err := targetFor(r.sess, src, deployOpts{})
if err != nil {
b.setStatus(colErr, err.Error())
return
}
name, ok := b.input(SF("name the new machine from %s: ", src.name))
if !ok || name == "" {
b.setStatus(colDim, "nothing done")
return
}
if err := checkName(name); err != nil {
b.setStatus(colWarn, err.Error())
return
}
// What the guest is to be told about itself, where the vCenter has anything
// to tell it with. A server with no customisation specifications is not
// asked the question — the answer could only be "none" — but it is told
// why it was not asked.
//
// Saying so is the whole point. Both of these used to be silent: a vCenter
// with no specifications and one that would not let them be read looked
// exactly like a step that had gone missing, and the warning that was set
// for the second went straight into the line the next question draws over.
// Two roads, and the picker offers whichever are open: the specifications
// the vCenter holds, and gvm's own, which it writes from ~/.gvmrc. A site
// with neither is told which of the two to set up rather than left with a
// step that looks broken.
specs, err := specNames(r.sess)
own := b.site.ready() && !src.windows()
switch {
case err != nil && !own:
// Short on purpose: this shares a line with the placement, and a line
// 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)")
return
case len(specs) == 0 && !own:
why := SF("%s has no customisation specifications", r.vc.Name)
if src.windows() {
why = SF("%s holds none, and gvm writes no Sysprep for a Windows template", r.vc.Name)
} else if miss := b.site.missing(); len(miss) > 0 {
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)
return
}
lines := []string{"leave the guest as the template made it"}
kinds := []string{""} // what each line means: "" none, "-" gvm's own, else a name
if own {
// What it will do, not where the settings are kept: a picker line that
// is mostly an absolute path says nothing about the choice being made,
// and `gvm config` is where the file is named.
lines = append(lines, SF("this site — %s, gateway %s", b.site.domain, b.site.gateway))
kinds = append(kinds, "-")
}
for _, name := range specs {
lines = append(lines, name)
kinds = append(kinds, name)
}
b.choose(SF("customise %s how", name), r, lines, func(i int) {
switch kinds[i] {
case "":
b.deployAsk(r, src, target, name, deployOpts{}, "")
case "-":
b.deployAddress(r, src, target, name, deployOpts{how: customSite, st: b.site})
default:
b.deployAddress(r, src, target, name,
deployOpts{how: customSpec, spec: kinds[i], st: b.site})
}
})
}
// deployAddress asks for the address, which is the other half of what a
// specification cannot know: it holds the netmask, the gateway and the domain,
// and the machine holds its own number.
//
// Empty is an answer: it leaves whatever the specification says, which is
// usually DHCP, and that is the ordinary case on a network that hands out
// addresses.
func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts) {
// 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 {
b.setStatus(colDim, "nothing done")
return
}
opts.ip = strings.TrimSpace(ip)
b.deployAsk(r, src, t, name, opts, "")
}
// deployAsk is the last step: what will happen, in full, and then the machine.
//
// 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) {
// 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)
}
parts = append(parts, "in "+t.describe()+", powered off")
if opts.customising() {
with := opts.spec
if opts.building() {
with = "named and addressed for " + opts.st.domain
if opts.ip != "" && gatewayOffSubnet(opts.ip, opts.st) {
parts = append(parts, SF("the gateway %s is not on %s's network", opts.st.gateway, opts.ip))
}
}
if opts.ip != "" {
with += " at " + opts.ip
}
parts = append(parts, with)
if toolsMissing(r.sess, src.ref) {
parts = append(parts, "no VMware Tools in the template, so this may not take")
}
}
where := strings.Join(parts, " · ")
if !b.askWith(SF("make %s from %s on %s?", name, src.name, r.vc.Name), where) {
b.setStatus(colDim, "nothing done")
return
}
b.working(SF("starting %s ...", name))
if _, err := startDeploy(r.sess, src, t, name, opts); err != nil {
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))
}
// working puts a line on the screen before an operation that will block the loop
// — a vCenter task can take minutes, and a terminal that goes silent for that
// long looks like a hang.
@@ -725,11 +965,6 @@ func (b *browser) renderPicker() {
cols, rows := termSize()
p := b.pick
what := "revert to which snapshot"
if p.kind == pickRemove {
what = "remove which snapshot"
}
visible := max(rows-5, 1)
if p.sel < p.scroll {
p.scroll = p.sel
@@ -737,21 +972,21 @@ func (b *browser) renderPicker() {
if p.sel >= p.scroll+visible {
p.scroll = p.sel - visible + 1
}
end := min(p.scroll+visible, len(p.items))
end := min(p.scroll+visible, len(p.lines))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
segLine(&sb, cols, seg{colTitle, what + " of " + p.row.name},
segLine(&sb, cols, seg{colTitle, p.title},
seg{colDim, " " + p.row.vc.Name})
segLine(&sb, cols)
for i := p.scroll; i < end; i++ {
e := p.items[i]
line := p.lines[i]
pointer, col := " ", colRow
if i == p.sel {
pointer, col = "▸ ", colRowSel
}
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.line()})
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, line})
}
for i := end - p.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
+27 -12
View File
@@ -121,6 +121,12 @@ func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
// isTemplate reports whether this is a template rather than a machine. vSphere
// takes a template's resource pool away and refuses to start it, snapshot it or
// reconfigure it: the only thing to be done with one is to make a machine from
// it (deploy.go).
func (r vmRow) isTemplate() bool { return r.vm.Summary.Config.Template }
func (r vmRow) powerShort() string {
switch r.power() {
case types.VirtualMachinePowerStatePoweredOn:
@@ -753,7 +759,8 @@ 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
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
// 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
@@ -796,10 +803,11 @@ type browser struct {
// browseVMs is the command: gather, then hand the terminal over to the loop.
//
// ssh is the command line the sheet's `h` runs, out of the configuration:
// the browser is handed it rather than reading it, so nothing in the interactive
// half has to know where settings come from.
func browseVMs(targets []VCenter, filter, ssh string) error {
// The configuration is handed in rather than read here, so that nothing in the
// interactive half has to know where a setting comes from: the browser keeps
// the two things it needs from it — the command `h` runs, and what a deployed
// guest is told about the network.
func browseVMs(targets []VCenter, filter string, cfg Config) error {
// Asked before anything else: the inventory sweep is three logins and a few
// seconds, and there is no point spending either on a screen that does not
// exist. It also keeps a stray `gvm` in a pipe or under cron from touching
@@ -808,7 +816,7 @@ func browseVMs(targets []VCenter, filter, ssh string) error {
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
}
b := &browser{targets: targets, filter: filter, ssh: ssh}
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH, site: cfg.site()}
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
@@ -1322,17 +1330,24 @@ type prompt struct {
text string
hint string
col string
// more is a second line, drawn where the help line goes. Only the sort
// legend uses it: a menu of thirteen choices does not fit across eighty
// columns, and the help line underneath it is describing keys that do
// nothing while a menu is waiting for one.
// more is a second line, drawn where the help line goes, for what will not
// fit in the question: the sort legend, which is thirteen choices and does
// not cross eighty columns, and the placement of a machine about to be
// deployed. The help line it covers is describing keys that do nothing
// while a question is waiting for one.
more string
}
// ask puts one question on the status line and waits for a single key. Only "y"
// means yes — every other key, Esc and Ctrl-C included, means no.
func (b *browser) ask(question string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
func (b *browser) ask(question string) bool { return b.askWith(question, "") }
// askWith is the same with something too long for the question on the help
// line under it — where a deployment puts the placement, which is the one thing
// about it worth reading twice and the one thing a status line would truncate.
func (b *browser) askWith(question, more string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no",
col: colPrompt, more: more}
b.render()
k := b.keys.next()
b.prompt = nil
+1 -1
View File
@@ -244,7 +244,7 @@ func inventoryAge() string {
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset", "--vm"}
"--off", "--reset", "--vm", "--from"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
+93
View File
@@ -31,6 +31,81 @@ type Config struct {
SMTPPort string // its port (default 25)
Telemetry string // URL `host -t` posts to; unset turns the posting off
SSH string // the command the sheet's `h` runs; %h is the machine
// What a deployed guest is told about the network it wakes up on
// (deploy.go). These are the site's answers and not the machine's: every
// machine gets the same domain, the same resolvers, the same mask and the
// same gateway, and only its own name and address differ. So they live
// here, where they are written once, and the command line carries the two
// that are about the one machine.
Domain string // fhi-berlin.mpg.de
DNS string // one or more resolvers, separated by commas
Netmask string // 255.255.255.0
Gateway string // 10.0.0.1
Timezone string // Europe/Berlin
}
// site is those five, as the deployment uses them: the list of resolvers split
// out, and a way to ask whether there is enough here to describe a network at
// all.
type site struct {
domain string
dns []string
netmask string
gateway string
timezone string
}
func (c Config) site() site {
s := site{domain: strings.TrimSpace(c.Domain), netmask: strings.TrimSpace(c.Netmask),
gateway: strings.TrimSpace(c.Gateway), timezone: strings.TrimSpace(c.Timezone)}
for _, d := range strings.Split(c.DNS, ",") {
if d = strings.TrimSpace(d); d != "" {
s.dns = append(s.dns, d)
}
}
return s
}
// ready reports whether gvm has been told enough to write a network into a
// guest. The domain, the mask and the gateway are the three a machine cannot
// be given an address without; the resolvers and the timezone are worth having
// and not worth refusing over.
func (s site) ready() bool {
return s.domain != "" && s.netmask != "" && s.gateway != ""
}
// describe is the site's network in one line, for `gvm config`: what a machine
// made from a template would be told, or what is still missing before one can
// be told anything.
func (s site) describe() string {
if !s.ready() {
return SF("not set up — a deployment needs %s in %s",
strings.Join(s.missing(), ", "), configFile())
}
out := SF("%s · netmask %s · gateway %s", s.domain, s.netmask, s.gateway)
if len(s.dns) > 0 {
out += " · dns " + strings.Join(s.dns, ", ")
}
if s.timezone != "" {
out += " · " + s.timezone
}
return out
}
// missing names what is not there, for a message that says what to write in
// ~/.gvmrc rather than only that something is missing.
func (s site) missing() []string {
var out []string
for _, f := range []struct {
name string
val string
}{{"domain", s.domain}, {"netmask", s.netmask}, {"gateway", s.gateway}} {
if f.val == "" {
out = append(out, f.name)
}
}
return out
}
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
@@ -412,6 +487,11 @@ func applyConfig(c *Config, m map[string]string) {
set("smtpport", &c.SMTPPort)
set("telemetry", &c.Telemetry)
set("ssh", &c.SSH)
set("domain", &c.Domain)
set("dns", &c.DNS)
set("netmask", &c.Netmask)
set("gateway", &c.Gateway)
set("timezone", &c.Timezone)
applyVCenters(c, m)
}
@@ -478,6 +558,11 @@ func applyEnv(c *Config) {
env("GVM_SMTPPORT", &c.SMTPPort)
env("GVM_TELEMETRY", &c.Telemetry)
env("GVM_SSH", &c.SSH)
env("GVM_DOMAIN", &c.Domain)
env("GVM_DNS", &c.DNS)
env("GVM_NETMASK", &c.Netmask)
env("GVM_GATEWAY", &c.Gateway)
env("GVM_TIMEZONE", &c.Timezone)
applyVCenterEnv(c)
}
@@ -587,6 +672,14 @@ func writeConfigTemplate(path string) {
b.WriteString("# smtpport = 25\n\n")
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
b.WriteString("# --- what a machine made from a template is told about the network ---\n")
b.WriteString("# The site's answers, written once. gvm adds the machine's own name and\n")
b.WriteString("# address to them: gvm new --from <template> --name web05 --ip 10.0.0.55\n")
b.WriteString("# domain = example.org\n")
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("# --- 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")
+702
View File
@@ -0,0 +1,702 @@
// deploy.go — making a new machine from a template.
//
// Everything else gvm does happens to a machine that already exists. This is the
// one thing that brings one into being, and that makes it the only operation
// here whose hardest question is not "may I" but "where".
//
// A template is not a machine that happens to be switched off. vSphere takes its
// resource pool away when it is marked as one, so there is nowhere for a clone
// of it to run until somebody says where — and that somewhere is the one thing
// that cannot be inherited from the source. Everything else can: the folder it
// sits in, the datastore it lives on, the hardware it was built with.
//
// So the placement is worked out before anything is asked, and the confirmation
// says it in full. "A new machine appeared somewhere on the estate" is not an
// outcome anybody should get from a keystroke.
//
// What this deliberately does not do is customise the guest — no hostname, no
// address, no domain join. That is a second machine's worth of vSphere
// (CustomizationSpec), it is site policy rather than a tool's business, and a
// half-done version of it that sets a hostname and leaves the address to DHCP
// would be worse than leaving it alone. The new machine is its template, under
// a new name, in a place somebody chose.
package main
import (
"net"
"sort"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// cloneWait is how long the command line watches a deployment before it stops
// watching. A clone copies every disk the template has, which on a fat template
// over a busy datastore is genuinely half an hour — so this is long, and like
// every other wait in gvm it is a limit on a frozen terminal rather than on the
// operation. The interactive half does not wait at all (see deploy).
const cloneWait = 60 * time.Minute
// maxVMName is what vSphere takes for a machine's name. Typing stops there
// rather than sending something the server will refuse.
const maxVMName = 80
// deployOpts is what a deployment may be told, beyond the name.
type deployOpts struct {
host string // land it here, by name; empty leaves the choice to the cluster
datastore string // empty means the template's own
powerOn bool
// What the guest is told about itself, and where that comes from.
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 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 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
// same thing that then happens.
type deployTarget struct {
folder types.ManagedObjectReference
pool types.ManagedObjectReference
where string // the cluster or host that pool belongs to, in words
host *types.ManagedObjectReference
hostName string
datastore *types.ManagedObjectReference
dsName string
}
// describe is the placement as the confirmation says it: where it will run, on
// which host if one was named, and on which datastore if one was.
func (t deployTarget) describe() string {
out := []string{t.where}
if t.hostName != "" {
out = append(out, "on "+t.hostName)
}
if t.dsName != "" {
out = append(out, "datastore "+t.dsName)
}
return strings.Join(out, " · ")
}
// templateProps are what has to be read of the source. The sweep does not carry
// the folder a machine sits in — nothing else needs it — and "is this a
// template" is in the summary the list already holds, but it is asked again
// here: this is the one place that acts on the answer.
var templateProps = []string{"name", "parent", "summary.config.template",
"summary.config.guestId", "summary.runtime.host"}
// deploySource is the template, read fresh.
type deploySource struct {
ref types.ManagedObjectReference
name string
folder types.ManagedObjectReference
host *types.ManagedObjectReference
template bool
guestID string // vSphere's own word for what is installed, e.g. ubuntu64Guest
}
// windows reports whether the template holds Windows, which decides whether gvm
// can write a customisation for it at all: Linux takes a LinuxPrep, which is
// five lines of network, and Windows takes a Sysprep, which is a licence key,
// an administrator password and a domain to join.
//
// From the guest id vSphere itself keeps, which is what the machine was created
// as. A template whose id says nothing is treated as Linux and left to the
// server to refuse — guessing "Windows" from silence would turn every template
// with an unset id into one gvm will not deploy.
func (src deploySource) windows() bool {
return strings.Contains(strings.ToLower(src.guestID), "windows")
}
func sourceOf(s *session, ref types.ManagedObjectReference) (deploySource, error) {
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, templateProps, &mvm); err != nil {
return deploySource{}, errf("%s: cannot read %s: %w", s.vc.Name, ref.Value, err)
}
src := deploySource{ref: ref, name: mvm.Name, host: mvm.Summary.Runtime.Host,
guestID: mvm.Summary.Config.GuestId}
if c := mvm.Summary.Config; c.Template {
src.template = true
}
if mvm.Parent == nil {
return src, errf("%s is in no folder, so there is nowhere to put a copy of it", src.name)
}
src.folder = *mvm.Parent
return src, nil
}
// targetFor works out where the new machine goes.
//
// The resource pool is the part that cannot be left out. A template has none of
// its own — vSphere takes it away when a machine is marked as one — so the pool
// of whatever the template is registered on is used instead: the cluster's,
// where it is in one, which leaves the choice of host to DRS the way every other
// deployment on that cluster does. Naming a host overrides both, and pins it.
func targetFor(s *session, src deploySource, opts deployOpts) (deployTarget, error) {
t := deployTarget{folder: src.folder}
hostRef := src.host
if opts.host != "" {
ref, name, err := hostByName(s, opts.host)
if err != nil {
return t, err
}
hostRef, t.host, t.hostName = &ref, &ref, name
}
if hostRef == nil {
return t, errf("%s is not registered on any host, so there is nothing to work out where a copy of it would run", src.name)
}
pool, where, err := poolOfHost(s, *hostRef)
if err != nil {
return t, err
}
t.pool, t.where = pool, where
if opts.datastore != "" {
ref, name, err := datastoreByName(s, opts.datastore)
if err != nil {
return t, err
}
t.datastore, t.dsName = &ref, name
}
return t, nil
}
// poolOfHost is the root resource pool of whatever a host belongs to, and the
// name of that thing. For a host in a cluster this is the cluster's pool, which
// is what makes DRS place the machine; for a standalone host it is the host's
// own.
func poolOfHost(s *session, host types.ManagedObjectReference) (types.ManagedObjectReference, string, error) {
var hosts []mo.HostSystem
if err := s.objects([]types.ManagedObjectReference{host}, []string{"name", "parent"}, &hosts); err != nil {
return types.ManagedObjectReference{}, "", err
}
if len(hosts) == 0 || hosts[0].Parent == nil {
return types.ManagedObjectReference{}, "", errf("%s: cannot tell what %s belongs to", s.vc.Name, host.Value)
}
var crs []mo.ComputeResource
if err := s.objects([]types.ManagedObjectReference{*hosts[0].Parent}, []string{"name", "resourcePool"}, &crs); err != nil {
return types.ManagedObjectReference{}, "", err
}
if len(crs) == 0 || crs[0].ResourcePool == nil {
return types.ManagedObjectReference{}, "", errf("%s: %s has no resource pool to run a machine in",
s.vc.Name, shortHost(hosts[0].Name))
}
where := crs[0].Name
if where == hosts[0].Name {
where = shortHost(where) // a standalone host's compute resource is named after it
}
return *crs[0].ResourcePool, where, nil
}
// hostByName and datastoreByName resolve what was asked for by name, and say
// what there was when it is not found: a typo answered with "no such host" and
// nothing else is a puzzle, and the list is short.
func hostByName(s *session, name string) (types.ManagedObjectReference, string, error) {
hosts, err := s.hosts("name")
if err != nil {
return types.ManagedObjectReference{}, "", err
}
var had []string
for _, h := range hosts {
short := shortHost(h.Name)
if strings.EqualFold(short, name) || strings.EqualFold(h.Name, name) {
return h.Reference(), short, nil
}
had = append(had, short)
}
return types.ManagedObjectReference{}, "", errf("%s has no host called %q — it has %s",
s.vc.Name, name, strings.Join(had, ", "))
}
func datastoreByName(s *session, name string) (types.ManagedObjectReference, string, error) {
stores, err := s.datastores("name")
if err != nil {
return types.ManagedObjectReference{}, "", err
}
var had []string
for _, d := range stores {
if strings.EqualFold(d.Name, name) {
return d.Reference(), d.Name, nil
}
had = append(had, d.Name)
}
return types.ManagedObjectReference{}, "", errf("%s has no datastore called %q — it has %s",
s.vc.Name, name, strings.Join(had, ", "))
}
// checkName is what vSphere will take, asked before it is typed into a
// confirmation rather than after.
func checkName(name string) error {
n := strings.TrimSpace(name)
switch {
case n == "":
return errf("the new machine needs a name")
case len(n) > maxVMName:
return errf("%d characters is longer than the %d vSphere takes for a name", len(n), maxVMName)
case strings.ContainsAny(n, "/\\"):
return errf("a machine's name cannot hold a slash")
}
return nil
}
// nameTaken reports whether the server already has a machine of that name.
// vSphere refuses a duplicate itself, several seconds into the clone; asking
// first turns that into an answer before anything starts.
func nameTaken(s *session, name string) bool {
vm, err := s.vm(name)
return err == nil && vm != nil
}
// cloneSpec is what is sent: where it goes, what the guest is told about
// itself, and that what comes out is a machine. Its own function so all of that
// can be checked without a server — a spec built twice, once in the code and
// once in a test, proves nothing.
//
// Template is false and stated rather than left out: cloning a template
// produces another template unless something says otherwise, and a second
// template nobody asked for is the kind of thing that is only noticed weeks
// later, when somebody wonders why the machine will not start.
func cloneSpec(t deployTarget, opts deployOpts, custom *types.CustomizationSpec) types.VirtualMachineCloneSpec {
return types.VirtualMachineCloneSpec{
Location: types.VirtualMachineRelocateSpec{
Pool: &t.pool,
Host: t.host,
Datastore: t.datastore,
},
Customization: custom,
PowerOn: opts.powerOn,
Template: false,
}
}
// ------------------------------------------------------- telling the guest
// specNames are the customisation specifications this vCenter holds, in the
// order it lists them.
func specNames(s *session) ([]string, error) {
m := object.NewCustomizationSpecManager(s.client.Client)
info, err := m.Info(s.ctx)
if err != nil {
return nil, errf("%s: cannot read the customisation specifications: %w", s.vc.Name, err)
}
out := make([]string, 0, len(info))
for _, i := range info {
out = append(out, i.Name)
}
sort.Strings(out)
return out, nil
}
// customizationFor is the specification the vCenter holds, with this machine's
// own two facts written into it.
//
// Only those two. Everything else the specification says — the domain, the DNS
// servers, the netmask, the gateway, the timezone, whether the guest is Linux
// or Windows — is the site's answer, kept where the site keeps it. gvm knowing
// better than the vCenter about any of that is how a tool ends up with a
// network policy of its own that nobody remembers agreeing to.
func customizationFor(s *session, opts deployOpts, src deploySource, name string) (*types.CustomizationSpec, error) {
if opts.building() {
return builtSpec(opts, src, name)
}
m := object.NewCustomizationSpecManager(s.client.Client)
item, err := m.GetCustomizationSpec(s.ctx, strings.TrimSpace(opts.spec))
if err != nil {
had, _ := specNames(s)
if len(had) == 0 {
return nil, errf("%s has no customisation specification called %q, and none at all — "+
"they are made in the vSphere client, under Policies and Profiles",
s.vc.Name, opts.spec)
}
return nil, errf("%s has no customisation specification called %q — it has %s",
s.vc.Name, opts.spec, strings.Join(had, ", "))
}
spec := item.Spec
host := strings.TrimSpace(opts.hostname)
if host == "" {
host = name // the machine's own name, which is what one means by a hostname
}
if err := setHostName(&spec, host); err != nil {
return nil, err
}
if opts.ip != "" {
if err := setAddress(&spec, opts.ip); err != nil {
return nil, err
}
}
return &spec, nil
}
// builtSpec is the customisation gvm writes itself: the site's answers from
// ~/.gvmrc, and this machine's name and address.
//
// It is Linux only, and says so rather than producing something that half
// works. Windows is a Sysprep — a licence key, an administrator password, a
// domain to join and a workgroup if it does not — and none of that is a thing
// to keep in a configuration file next to the SMTP relay. A site with Windows
// templates wants a specification in the vCenter, which is what --spec is for.
func builtSpec(opts deployOpts, src deploySource, name string) (*types.CustomizationSpec, error) {
if src.windows() {
return nil, errf("%s is a Windows template, and gvm does not write a Sysprep — "+
"make a specification in the vSphere client and name it with --spec", src.name)
}
st := opts.st
if !st.ready() {
return nil, errf("a machine cannot be told about the network until %s says %s — "+
"or name a specification the vCenter holds with --spec",
configFile(), strings.Join(st.missing(), ", "))
}
host := strings.TrimSpace(opts.hostname)
if host == "" {
host = name
}
adapter := types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: st.netmask,
Gateway: []string{st.gateway},
DnsServerList: st.dns,
}
if ip := strings.TrimSpace(opts.ip); ip != "" {
if err := checkAddress(ip, st); err != nil {
return nil, err
}
adapter.Ip = &types.CustomizationFixedIp{IpAddress: ip}
}
spec := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{
HostName: &types.CustomizationFixedName{Name: host},
Domain: st.domain,
TimeZone: st.timezone,
HwClockUTC: types.NewBool(true),
},
GlobalIPSettings: types.CustomizationGlobalIPSettings{
DnsServerList: st.dns,
DnsSuffixList: []string{st.domain},
},
NicSettingMap: []types.CustomizationAdapterMapping{{Adapter: adapter}},
}
return spec, nil
}
// checkAddress is what gvm can tell about an address before the guest has it:
// that it is one, that the mask and the gateway are, and that the gateway is
// somewhere the machine could reach. The last is a warning's worth of wrong
// rather than an error's — a routed setup can put a gateway anywhere — but
// almost every time it is a typo, and a machine with an unreachable gateway is
// one somebody drives to the console for.
func checkAddress(ip string, st site) error {
addr := net.ParseIP(strings.TrimSpace(ip))
if addr == nil || addr.To4() == nil {
return errf("%q is not an IPv4 address", ip)
}
mask := net.ParseIP(st.netmask)
if mask == nil || mask.To4() == nil {
return errf("the netmask in %s is %q, which is not one", configFile(), st.netmask)
}
gw := net.ParseIP(st.gateway)
if gw == nil || gw.To4() == nil {
return errf("the gateway in %s is %q, which is not an address", configFile(), st.gateway)
}
return nil
}
// gatewayOffSubnet reports whether the gateway is outside the network the
// address and mask describe — said on the confirmation rather than refused.
func gatewayOffSubnet(ip string, st site) bool {
addr, gw := net.ParseIP(strings.TrimSpace(ip)).To4(), net.ParseIP(st.gateway).To4()
mask := net.ParseIP(st.netmask).To4()
if addr == nil || gw == nil || mask == nil {
return false // not knowing is not the same as knowing it is wrong
}
m := net.IPMask(mask)
return !addr.Mask(m).Equal(gw.Mask(m))
}
// setHostName writes the name into whichever kind of identity the
// specification carries. The two that matter are Linux and Windows; the third
// is a Windows answer file somebody wrote by hand, and a tool that reached into
// that to change one line would be guessing at a format it does not own.
func setHostName(spec *types.CustomizationSpec, name string) error {
fixed := &types.CustomizationFixedName{Name: name}
switch id := spec.Identity.(type) {
case *types.CustomizationLinuxPrep:
id.HostName = fixed
case *types.CustomizationSysprep:
id.UserData.ComputerName = fixed
case *types.CustomizationSysprepText:
return errf("that specification is a Windows answer file written by hand — " +
"gvm will not edit one; put the machine's name in the file, or use a specification with a name field")
case *types.CustomizationCloudinitPrep:
return errf("that specification is a cloud-init one, which carries the hostname inside its " +
"metadata — gvm does not rewrite that; leave --hostname off and let cloud-init set it")
default:
return errf("that specification has no kind of identity gvm knows how to name (%T)", spec.Identity)
}
return nil
}
// setAddress puts a fixed address on the specification's first adapter, and
// keeps everything else about it.
//
// The netmask and the gateway are deliberately not asked for: they belong to
// the network, the specification already carries them, and a machine given an
// address with a mask invented by the tool that deployed it is a machine that
// half works. Where the specification has no mask — because its adapter is set
// to DHCP — it is refused, because that is a question for the vCenter and not
// for a command line.
func setAddress(spec *types.CustomizationSpec, ip string) error {
if net.ParseIP(strings.TrimSpace(ip)) == nil {
return errf("%q is not an address", ip)
}
if len(spec.NicSettingMap) == 0 {
return errf("that specification has no network adapter in it, so there is nowhere to put an address")
}
nic := &spec.NicSettingMap[0]
if nic.Adapter.SubnetMask == "" {
return errf("that specification's adapter has no subnet mask — it is set to take one from DHCP, " +
"so an address given here would arrive without one; put a mask in the specification, or leave --ip off")
}
nic.Adapter.Ip = &types.CustomizationFixedIp{IpAddress: strings.TrimSpace(ip)}
return nil
}
// toolsMissing reports whether the template looks as though it has no VMware
// Tools, which is what carries out a customisation inside the guest.
//
// A guess, and treated as one: the version is what the machine last reported,
// so a template made from a machine that never ran says nothing here. It is
// worth saying anyway — finding out that the hostname was never set is
// otherwise something that happens twenty minutes later, on a guest that came
// up under the template's own name.
func toolsMissing(s *session, ref types.ManagedObjectReference) bool {
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, []string{"config.tools"}, &mvm); err != nil {
return false // not known is not the same as not there
}
return mvm.Config != nil && mvm.Config.Tools != nil && mvm.Config.Tools.ToolsVersion == 0
}
// startDeploy sends the clone and hands back the task without waiting for it.
// Who waits is the caller's business: the command line does, the interactive
// list does not (see deploy).
func startDeploy(s *session, src deploySource, t deployTarget, name string, opts deployOpts) (*object.Task, error) {
if !src.template {
return nil, errf("%s is a machine, not a template — gvm only makes copies of templates", src.name)
}
if err := checkName(name); err != nil {
return nil, err
}
if nameTaken(s, name) {
return nil, errf("%s already has a machine called %s", s.vc.Name, name)
}
var custom *types.CustomizationSpec
if opts.customising() {
var err error
if custom, err = customizationFor(s, opts, src, strings.TrimSpace(name)); err != nil {
return nil, err
}
}
vm := object.NewVirtualMachine(s.client.Client, src.ref)
folder := object.NewFolder(s.client.Client, t.folder)
task, err := vm.Clone(s.ctx, folder, strings.TrimSpace(name), cloneSpec(t, opts, custom))
if err != nil {
return nil, errf("%s: cannot start making %s from %s: %w", s.vc.Name, name, src.name, err)
}
return task, nil
}
// hostNameOf and addressOf read back what the specification will actually do,
// for the confirmation. Read back rather than repeated from the options: what
// is shown is then the thing that was built, not the thing that was asked for.
func hostNameOf(spec *types.CustomizationSpec) string {
var name types.BaseCustomizationName
switch id := spec.Identity.(type) {
case *types.CustomizationLinuxPrep:
name = id.HostName
case *types.CustomizationSysprep:
name = id.UserData.ComputerName
}
if fixed, ok := name.(*types.CustomizationFixedName); ok {
return fixed.Name
}
return "as the specification says"
}
func addressOf(spec *types.CustomizationSpec) string {
if len(spec.NicSettingMap) == 0 {
return "as the specification says"
}
nic := spec.NicSettingMap[0].Adapter
fixed, ok := nic.Ip.(*types.CustomizationFixedIp)
if !ok {
return "as the specification says (DHCP, most likely)"
}
out := fixed.IpAddress
if nic.SubnetMask != "" {
out += " netmask " + nic.SubnetMask
}
if len(nic.Gateway) > 0 {
out += " via " + strings.Join(nic.Gateway, ", ")
}
return out
}
// ---------------------------------------------------------- the command line
// specsCLI is `gvm new --specs`: what the vCenter has to customise a guest
// with, and nothing else. It exists because the question it answers is the one
// that follows "why was I not asked which specification to use" — and because
// a name typed at --spec is worth being able to look up.
func specsCLI(vc VCenter) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
names, err := specNames(s)
if err != nil {
return err
}
if len(names) == 0 {
PE(vc.Name+" holds no customisation specifications",
"they are made in the vSphere client, under Policies and Profiles")
return nil
}
for _, n := range names {
PO(n)
}
return nil
}
// deployCLI is `gvm new`: work out where it goes, say so, ask, and then wait —
// a script that gets its prompt back wants the machine to exist.
func deployCLI(vc VCenter, template, name string, opts deployOpts, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
if err := checkName(name); err != nil {
return err
}
vm, err := s.vm(template)
if err != nil {
return err
}
src, err := sourceOf(s, vm.Reference())
if err != nil {
return err
}
if !src.template {
return errf("%s is a machine, not a template — gvm only makes copies of templates", src.name)
}
t, err := targetFor(s, src, opts)
if err != nil {
return err
}
if nameTaken(s, name) {
return errf("%s already has a machine called %s", vc.Name, name)
}
started := "no — o in the list, or --on next time"
if opts.powerOn {
started = "yes, as soon as it is made"
}
facts := [][2]string{
{"from", src.name},
{"new machine", name},
{"where", t.describe()},
{"powered on", started},
}
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 {
return err
}
how := opts.spec
if opts.building() {
how = "from " + configFile()
}
facts = append(facts, [2]string{"customise", how}, [2]string{"hostname", hostNameOf(custom)})
facts = append(facts, [2]string{"address", addressOf(custom)})
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))
}
if toolsMissing(s, src.ref) {
PE(src.name + " reports no VMware Tools, which is what carries a customisation out inside " +
"the guest — the machine will be made either way, but it may come up as the template did")
}
}
ok, err := confirmFacts(vc, "make "+name+" from the template "+src.name, facts, yes)
if err != nil || !ok {
return err
}
task, err := startDeploy(s, src, t, name, opts)
if err != nil {
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))
return nil
}
+661
View File
@@ -0,0 +1,661 @@
package main
import (
"os"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/types"
)
// templateRow is a row vSphere would call a template rather than a machine.
func templateRow(name string) vmRow {
r := testRow(name, false, "-")
r.vm.Summary.Config.Template = true
return r
}
// What vSphere takes for a name, asked before it is typed into a confirmation
// rather than after it.
func TestCheckName(t *testing.T) {
for _, c := range []struct {
name string
bad string
}{
{name: "web05"},
{name: "web-05.example.org"},
{name: " spaced "}, // trimmed, and what is left is a name
{name: "", bad: "needs a name"},
{name: " ", bad: "needs a name"},
{name: strings.Repeat("x", maxVMName+1), bad: "longer than"},
{name: "web/05", bad: "slash"},
{name: `web\05`, bad: "slash"},
} {
err := checkName(c.name)
switch {
case c.bad == "":
if err != nil {
t.Errorf("%q was refused: %v", c.name, err)
}
case err == nil:
t.Errorf("%q was accepted, want a refusal mentioning %q", c.name, c.bad)
case !strings.Contains(err.Error(), c.bad):
t.Errorf("%q was refused with %q, which does not mention %q", c.name, err, c.bad)
}
}
}
// The placement is the one thing about a deployment worth reading twice, so it
// has to say all of what was decided and none of what was not.
func TestTheTargetDescribesItself(t *testing.T) {
if got := (deployTarget{where: "prod-cluster"}).describe(); got != "prod-cluster" {
t.Errorf("a cluster alone reads as %q", got)
}
got := deployTarget{where: "prod-cluster", hostName: "esx03", dsName: "ssd-2"}.describe()
for _, want := range []string{"prod-cluster", "esx03", "ssd-2"} {
if !strings.Contains(got, want) {
t.Errorf("the placement does not mention %q: %q", want, got)
}
}
// What was left to the cluster is not dressed up as a choice.
if strings.Contains((deployTarget{where: "prod-cluster"}).describe(), "on ") {
t.Errorf("a placement with no host named one anyway: %q", got)
}
}
// A template is not a machine that happens to be off. vSphere will not start
// one, snapshot one or reconfigure one, so the menu offers the single thing
// that can be done with it and says why the rest cannot — rather than leaving
// them out, which would make the menu change shape between rows.
func TestTheMenuOfATemplate(t *testing.T) {
b := &browser{}
menu := b.buildMenu(templateRow("web-template"), nil, testSizing())
first := menu[0]
if first.key != 'p' || !strings.Contains(first.label, "template") {
t.Fatalf("the first entry is %q (%q), want the one that deploys", string(first.key), first.label)
}
if !first.available() {
t.Errorf("deploying from a template is not offered: %s", first.why)
}
for _, m := range menu[1:] {
if m.isSeparator() {
continue
}
if m.available() {
t.Errorf("%q (%s) is offered on a template", string(m.key), m.label)
}
if !strings.Contains(m.why, "is a template") {
t.Errorf("%q is greyed out with %q, which does not say it is a template", string(m.key), m.why)
}
}
// And every letter still reaches exactly one entry, the new one included.
seen := map[rune]string{}
for _, m := range menu {
if m.isSeparator() {
continue
}
if other, ok := seen[m.key]; ok {
t.Errorf("%q is the letter for both %q and %q", string(m.key), other, m.label)
}
seen[m.key] = m.label
}
// An ordinary machine's menu is unchanged: no deploy entry on something
// there is no template to deploy from.
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.key == 'p' {
t.Error("an ordinary machine offers to deploy from itself")
}
}
}
// The row knows what it is, from the same property the sheet prints.
func TestARowKnowsATemplate(t *testing.T) {
if templateRow("web-template").isTemplate() != true {
t.Error("a template does not read as one")
}
if testRow("web01", true, "10.0.0.5").isTemplate() {
t.Error("an ordinary machine reads as a template")
}
// And the sheet says so where it says what the machine is.
sheet := sheetText(vmDetail(templateRow("web-template"), nil, ""))
if !strings.Contains(sheet, "template") {
t.Errorf("the sheet does not say it is a template:\n%s", sheet)
}
}
// Deploying from something that is not a template is refused before anything
// is sent, wherever it is asked from.
func TestDeployingFromAMachineIsRefused(t *testing.T) {
src := deploySource{name: "web01", template: false}
_, err := startDeploy(nil, src, deployTarget{}, "copy01", deployOpts{})
if err == nil {
t.Fatal("a machine was copied as though it were a template")
}
if !strings.Contains(err.Error(), "not a template") {
t.Errorf("it was refused with %q", err)
}
// The interactive half says the same thing rather than starting anything.
b := &browser{rows: []vmRow{testRow("web01", true, "10.0.0.5")}, view: []int{0}}
b.deploy(b.rows[0]) // no session: it gets no further than that
if !strings.Contains(b.status, "no connection") {
t.Errorf("it said %q", b.status)
}
}
// A deployment carries the placement into the spec it sends, and asks for a
// machine rather than another template.
func TestTheCloneSpecSaysWhereAndWhat(t *testing.T) {
pool := types.ManagedObjectReference{Type: "ResourcePool", Value: "resgroup-9"}
host := types.ManagedObjectReference{Type: "HostSystem", Value: "host-3"}
ds := types.ManagedObjectReference{Type: "Datastore", Value: "datastore-7"}
target := deployTarget{pool: pool, host: &host, datastore: &ds, where: "prod"}
spec := cloneSpec(target, deployOpts{}, nil)
if *spec.Location.Pool != pool {
t.Error("the pool did not reach the spec")
}
if spec.Location.Host == nil || *spec.Location.Host != host {
t.Error("the host did not reach the spec")
}
if spec.Location.Datastore == nil || *spec.Location.Datastore != ds {
t.Error("the datastore did not reach the spec")
}
if spec.Template {
t.Error("the copy would be another template")
}
if spec.PowerOn {
t.Error("it would be started, though nothing asked for that")
}
// What was left to the cluster is left out of the spec rather than filled
// in with something: a nil host is vSphere being asked to place it.
bare := cloneSpec(deployTarget{pool: pool}, deployOpts{powerOn: true}, nil)
if bare.Location.Host != nil || bare.Location.Datastore != nil {
t.Error("a placement that named neither a host nor a datastore invented one")
}
if !bare.PowerOn {
t.Error("--on did not reach the spec")
}
if bare.Customization != nil {
t.Error("a deployment that was told nothing about the guest customises it anyway")
}
}
// gvm writes two facts into a customisation specification and leaves every
// other thing in it alone. These are the ones it cannot get from a server, so
// they are checked without one.
func TestWritingTheTwoFactsIntoASpecification(t *testing.T) {
linux := func() *types.CustomizationSpec {
return &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{
Domain: "fhi-berlin.mpg.de",
HostName: &types.CustomizationFixedName{Name: "the-template"},
},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: "255.255.255.0",
Gateway: []string{"10.0.0.1"},
},
}},
}
}
// The hostname goes in, and the domain it belongs to is not touched.
spec := linux()
if err := setHostName(spec, "web05"); err != nil {
t.Fatalf("setHostName: %v", err)
}
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname came out as %q", got)
}
if d := spec.Identity.(*types.CustomizationLinuxPrep).Domain; d != "fhi-berlin.mpg.de" {
t.Errorf("the domain was changed to %q", d)
}
// The address goes in, and the netmask and gateway the site chose stay.
if err := setAddress(spec, "10.0.0.55"); err != nil {
t.Fatalf("setAddress: %v", err)
}
nic := spec.NicSettingMap[0].Adapter
fixed, ok := nic.Ip.(*types.CustomizationFixedIp)
if !ok || fixed.IpAddress != "10.0.0.55" {
t.Errorf("the address came out as %#v", nic.Ip)
}
if nic.SubnetMask != "255.255.255.0" || len(nic.Gateway) != 1 || nic.Gateway[0] != "10.0.0.1" {
t.Errorf("the netmask or the gateway was rewritten: %q %v", nic.SubnetMask, nic.Gateway)
}
// Windows is the other identity that has a name field.
win := &types.CustomizationSpec{Identity: &types.CustomizationSysprep{}}
if err := setHostName(win, "WEB05"); err != nil {
t.Fatalf("a Windows specification was refused: %v", err)
}
if got := hostNameOf(win); got != "WEB05" {
t.Errorf("the Windows computer name came out as %q", got)
}
// And the ones gvm will not reach into, each saying why rather than
// quietly deploying a machine under the template's own name.
for _, c := range []struct {
what string
spec *types.CustomizationSpec
says string
}{
{"a hand-written answer file", &types.CustomizationSpec{
Identity: &types.CustomizationSysprepText{}}, "answer file"},
{"a cloud-init specification", &types.CustomizationSpec{
Identity: &types.CustomizationCloudinitPrep{}}, "cloud-init"},
{"nothing at all", &types.CustomizationSpec{}, "no kind of identity"},
} {
err := setHostName(c.spec, "web05")
if err == nil {
t.Errorf("%s was named anyway", c.what)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s was refused with %q, which does not mention %q", c.what, err, c.says)
}
}
}
// An address is refused where it would arrive without a netmask, because a
// machine with an address and no mask half works — and where to get the mask
// from is a question for the vCenter, not for a command line.
func TestAnAddressNeedsAMaskToGoWith(t *testing.T) {
dhcp := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{Ip: &types.CustomizationDhcpIpGenerator{}},
}},
}
err := setAddress(dhcp, "10.0.0.55")
if err == nil {
t.Fatal("an address was written into a specification with no netmask")
}
if !strings.Contains(err.Error(), "subnet mask") {
t.Errorf("it was refused with %q", err)
}
// A specification with no adapter at all has nowhere to put one.
if err := setAddress(&types.CustomizationSpec{}, "10.0.0.55"); err == nil {
t.Error("an address was written into a specification with no adapter")
}
// And what is not an address is not one.
withNic := &types.CustomizationSpec{
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{SubnetMask: "255.255.255.0"},
}},
}
for _, bad := range []string{"ten.oh.oh.oh", "10.0.0.555", "web05", ""} {
if err := setAddress(withNic, bad); err == nil {
t.Errorf("%q was taken for an address", bad)
}
}
}
// What the confirmation shows is read back off the specification that was
// built, so it says what will happen rather than what was asked for.
func TestTheConfirmationReadsBackWhatWasBuilt(t *testing.T) {
spec := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: "255.255.255.0",
Gateway: []string{"10.0.0.1"},
},
}},
}
// Left as the specification has it: said so, rather than shown as blank.
if got := addressOf(spec); !strings.Contains(got, "specification") {
t.Errorf("an address left to the specification reads as %q", got)
}
if got := hostNameOf(spec); !strings.Contains(got, "specification") {
t.Errorf("a name left to the specification reads as %q", got)
}
_ = setHostName(spec, "web05")
_ = setAddress(spec, "10.0.0.55")
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") ||
!strings.Contains(got, "255.255.255.0") || !strings.Contains(got, "10.0.0.1") {
t.Errorf("the address line does not carry all three: %q", got)
}
}
// A step that does not happen has to say why. A vCenter with no customisation
// specifications, and one that will not let them be read, both used to skip
// the question in silence — which looks exactly like a step that is broken,
// and the warning set for the second went straight into the line the next
// question draws over.
func TestAMissingStepSaysWhyItIsMissing(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "24")
for _, note := range []string{
"v308 has no customisation specifications",
"the customisation specifications could not be read (gvm new --specs)",
} {
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("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)
frame := stripEscapes(<-drawn)
pw.Close()
pr.Close()
kw.Close()
if !strings.Contains(frame, note) {
t.Errorf("the reason is not on the screen, or was cut short:\n%s", lastLine(frame))
}
// And the placement is still there beside it: the line has to hold both
// on an ordinary terminal.
if !strings.Contains(frame, "prod-cluster") {
t.Errorf("the reason pushed the placement off the line:\n%s", lastLine(frame))
}
if !strings.Contains(frame, "make web05 from ubuntu-tpl") {
t.Errorf("the question itself is missing:\n%s", lastLine(frame))
}
}
}
// lastLine is the bottom of a drawn frame, for a message about it.
func lastLine(frame string) string {
lines := strings.Split(strings.ReplaceAll(strings.TrimRight(frame, "\n"), "\r", ""), "\n")
if len(lines) < 2 {
return frame
}
return strings.Join(lines[len(lines)-2:], "\n")
}
// testSite is a site that has been told everything it needs.
func testSite() site {
return site{domain: "fhi-berlin.mpg.de", dns: []string{"10.0.0.1", "10.0.0.2"},
netmask: "255.255.255.0", gateway: "10.0.0.1", timezone: "Europe/Berlin"}
}
// The specification gvm writes itself: the site's answers from the
// configuration, this machine's two facts, and nothing invented.
func TestTheSpecificationGvmWritesItself(t *testing.T) {
linux := deploySource{name: "ubuntu-tpl", guestID: "ubuntu64Guest"}
opts := deployOpts{st: testSite(), ip: "10.0.0.55"}
spec, err := builtSpec(opts, linux, "web05")
if err != nil {
t.Fatalf("builtSpec: %v", err)
}
// The machine's own two facts.
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname is %q", got)
}
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") {
t.Errorf("the address is %q", got)
}
// And the site's, carried through rather than made up.
id, ok := spec.Identity.(*types.CustomizationLinuxPrep)
if !ok {
t.Fatalf("the identity is %T, want a Linux one", spec.Identity)
}
if id.Domain != "fhi-berlin.mpg.de" {
t.Errorf("the domain is %q", id.Domain)
}
if id.TimeZone != "Europe/Berlin" {
t.Errorf("the timezone is %q", id.TimeZone)
}
if len(spec.GlobalIPSettings.DnsServerList) != 2 {
t.Errorf("the resolvers are %v", spec.GlobalIPSettings.DnsServerList)
}
if len(spec.GlobalIPSettings.DnsSuffixList) != 1 ||
spec.GlobalIPSettings.DnsSuffixList[0] != "fhi-berlin.mpg.de" {
t.Errorf("the search domain is %v", spec.GlobalIPSettings.DnsSuffixList)
}
nic := spec.NicSettingMap[0].Adapter
if nic.SubnetMask != "255.255.255.0" || len(nic.Gateway) != 1 || nic.Gateway[0] != "10.0.0.1" {
t.Errorf("the adapter got mask %q gateway %v", nic.SubnetMask, nic.Gateway)
}
// --hostname wins over the machine's name where the two differ.
named := opts
named.hostname = "web05.fhi-berlin.mpg.de"
spec, err = builtSpec(named, linux, "web05")
if err != nil {
t.Fatalf("builtSpec with a hostname: %v", err)
}
if got := hostNameOf(spec); got != "web05.fhi-berlin.mpg.de" {
t.Errorf("--hostname was ignored: %q", got)
}
// With no address the adapter is left on DHCP, which is a whole answer:
// the name is still set, and that is what was asked for.
spec, err = builtSpec(deployOpts{st: testSite(), hostname: "web05"}, linux, "web05")
if err != nil {
t.Fatalf("builtSpec without an address: %v", err)
}
if _, fixed := spec.NicSettingMap[0].Adapter.Ip.(*types.CustomizationFixedIp); fixed {
t.Error("an address was invented where none was given")
}
}
// Two things it will not write, each saying what to do instead rather than
// producing something that half works.
func TestWhatGvmWillNotWriteItself(t *testing.T) {
// Windows, which is a Sysprep: a licence key, an administrator password
// and a domain to join, none of which belongs in a configuration file next
// to the SMTP relay.
win := deploySource{name: "win2022-tpl", guestID: "windows2019srv_64Guest"}
_, err := builtSpec(deployOpts{st: testSite(), ip: "10.0.0.55"}, win, "web05")
if err == nil {
t.Fatal("a Sysprep was written for a Windows template")
}
if !strings.Contains(err.Error(), "--spec") {
t.Errorf("it was refused without naming the way round it: %v", err)
}
// And a site that has not been told enough, named field by field.
linux := deploySource{name: "ubuntu-tpl", guestID: "ubuntu64Guest"}
_, err = builtSpec(deployOpts{st: site{domain: "example.org"}, ip: "10.0.0.55"}, linux, "web05")
if err == nil {
t.Fatal("a network was written out of a configuration that has none")
}
for _, want := range []string{"netmask", "gateway"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q: %v", want, err)
}
}
// A template whose guest id says nothing is taken for Linux: guessing
// Windows from silence would refuse every template with an unset id.
quiet := deploySource{name: "tpl"}
if _, err := builtSpec(deployOpts{st: testSite()}, quiet, "web05"); err != nil {
t.Errorf("a template with no guest id was refused: %v", err)
}
}
// What gvm can tell about an address before the guest has it.
func TestCheckAddressAndTheGateway(t *testing.T) {
st := testSite()
for _, bad := range []string{"ten.oh.oh.oh", "10.0.0.555", "", "2001:db8::1"} {
if err := checkAddress(bad, st); err == nil {
t.Errorf("%q was taken for an IPv4 address", bad)
}
}
if err := checkAddress("10.0.0.55", st); err != nil {
t.Errorf("a good address was refused: %v", err)
}
// Nonsense in the configuration is named as the configuration's.
for _, broken := range []site{
{domain: "x", netmask: "not-a-mask", gateway: "10.0.0.1"},
{domain: "x", netmask: "255.255.255.0", gateway: "over-there"},
} {
err := checkAddress("10.0.0.55", broken)
if err == nil {
t.Errorf("%+v was accepted", broken)
continue
}
if !strings.Contains(err.Error(), configFile()) {
t.Errorf("it does not say where to fix it: %v", err)
}
}
// A gateway on another network is a warning's worth of wrong, not an
// error's — but it is said.
if gatewayOffSubnet("10.0.0.55", st) {
t.Error("a gateway on the machine's own network was called foreign")
}
if !gatewayOffSubnet("192.168.5.10", st) {
t.Error("a gateway on another network went unremarked")
}
// And what cannot be judged is not judged.
if gatewayOffSubnet("10.0.0.55", site{netmask: "nonsense", gateway: "10.0.0.1"}) {
t.Error("an unreadable netmask produced a verdict anyway")
}
}
// The site is read out of the configuration the way every other setting is.
func TestTheSiteComesOutOfTheConfiguration(t *testing.T) {
cfg := Config{Domain: " example.org ", DNS: "10.0.0.1, 10.0.0.2 ,",
Netmask: "255.255.255.0", Gateway: "10.0.0.1", Timezone: "Europe/Berlin"}
st := cfg.site()
if st.domain != "example.org" {
t.Errorf("the domain came out as %q", st.domain)
}
if len(st.dns) != 2 || st.dns[0] != "10.0.0.1" || st.dns[1] != "10.0.0.2" {
t.Errorf("the resolvers came out as %v", st.dns)
}
if !st.ready() {
t.Errorf("a configuration with all of it is not ready: %v", st.missing())
}
// And one that is not says which parts are missing, in the words of the
// settings somebody would have to write.
half := Config{Domain: "example.org"}.site()
if half.ready() {
t.Error("a configuration with no netmask or gateway says it is ready")
}
if got := half.missing(); len(got) != 2 {
t.Errorf("it names %v as missing", got)
}
if !strings.Contains(Config{}.site().describe(), "not set up") {
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")
}
}
+50 -3
View File
@@ -47,7 +47,7 @@ var helpTail = strings.Join([]string{
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.2.0"
var version = "1.3.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -145,6 +145,25 @@ func run() error {
subSize.String(&szCPUs, "c", "cpus", "Set its vCPU count")
subSize.String(&szMemory, "m", "memory", "Set its memory, in GB (or 512m for MB)")
// Two things again, so both are flags of their own: what to copy and what to
// call the copy. --host and --datastore are the two parts of the placement
// that are not inherited from the template.
var newFrom, newName, newHost, newStore string
var newSpec, newIP, newHostname string
var newSpecs bool
var newOn bool
subNew := flaggy.NewSubcommand("new")
subNew.Description = "Make a new machine from a template"
subNew.String(&newFrom, "", "from", "The template to copy")
subNew.String(&newName, "", "name", "What to call the new machine")
subNew.String(&newHost, "", "host", "Put it on this host, rather than letting its cluster decide")
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(&newHostname, "", "hostname", "Name the guest this, rather than after the machine")
subNew.Bool(&newSpecs, "", "specs", "List the customisation specifications this vCenter holds")
var hostCount, hostTelemetry bool
subHost := flaggy.NewSubcommand("host")
subHost.Description = "Host commands"
@@ -173,6 +192,7 @@ func run() error {
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subNew, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
@@ -211,7 +231,7 @@ func run() error {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch, cfg.SSH)
return browseVMs(targets, vmMatch, cfg)
case subSnap.Used:
if snapOld {
@@ -290,6 +310,32 @@ func run() error {
}
return sizeCLI(vc, szVM, szCPUs, szMemory, yes)
case subNew.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
if newSpecs {
return specsCLI(vc)
}
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, how: how,
spec: newSpec, ip: newIP, hostname: newHostname, st: cfg.site(),
}, yes)
case subHost.Used:
vc, err := cfg.pick(vcname)
if err != nil {
@@ -346,7 +392,7 @@ func run() error {
if err != nil {
return err
}
return browseVMs(targets, "", cfg.SSH)
return browseVMs(targets, "", cfg)
}
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
@@ -378,6 +424,7 @@ func showConfig(cfg Config) error {
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
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())
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
+19 -1
View File
@@ -69,6 +69,23 @@ smtpport = 25
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- what a machine made from a template is told about the network ---
# The site's answers, written once: every machine made from a template gets the
# same domain, the same resolvers, the same mask and the same gateway, and only
# its own name and address differ. Those two are typed:
#
# gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55
#
# Linux only — a Windows guest needs a Sysprep, which is a licence key and an
# administrator password, and that belongs in a customisation specification in
# the vCenter (gvm new --spec). `gvm config` shows what would be written and
# names whatever is still missing.
# domain = example.org
# dns = 10.0.0.1, 10.0.0.2
# netmask = 255.255.255.0
# gateway = 10.0.0.1
# timezone = Europe/Berlin
# --- ssh, for the sheet's 'h' ---
# The command that logs in to a machine's guest from its sheet. "%h" is where
# the guest's own hostname — or its address, when it reports no name — is put;
@@ -84,7 +101,8 @@ telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# Every setting above has an environment spelling that wins over the file:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY, GVM_SSH
# GVM_TELEMETRY, GVM_SSH, GVM_DOMAIN, GVM_DNS, GVM_NETMASK, GVM_GATEWAY,
# GVM_TIMEZONE
# 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,
+183
View File
@@ -1219,6 +1219,189 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
}
}
// Making a machine from a template, against a server that answers: the source
// has to be a template, the placement is worked out rather than asked for, and
// the machine that comes out is a machine and not another template.
func TestSimDeployFromATemplate(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
// An ordinary machine is refused before anything is sent: gvm copies
// templates, and a clone of a running machine is a different operation with
// different consequences.
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
if src.template {
t.Fatal("the simulator handed back a template where a machine was asked for")
}
if _, err := startDeploy(s, src, deployTarget{}, "copy01", deployOpts{}); err == nil {
t.Error("a machine was copied as though it were a template")
}
// Make it one. A template has no resource pool of its own from here on,
// which is the whole reason the placement has to be worked out.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine to template it: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err = sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf after templating: %v", err)
}
if !src.template {
t.Fatal("a machine marked as a template does not read as one")
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
if target.where == "" {
t.Error("the placement has nothing to say where it would run")
}
if target.pool.Value == "" {
t.Error("no resource pool was worked out, so nothing could run")
}
// A name that is already taken is refused before the clone starts, rather
// than several seconds in by vCenter.
if _, err := startDeploy(s, src, target, "DC0_C0_RP0_VM1", deployOpts{}); err == nil {
t.Error("a name that is already in use was accepted")
}
task, err := startDeploy(s, src, target, "made-from-template", deployOpts{})
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
// It exists, it is a machine rather than a template, and it is where it was
// said it would be.
made, err := s.vm("made-from-template")
if err != nil {
t.Fatalf("the new machine cannot be found: %v", err)
}
var mvm mo.VirtualMachine
if err := made.Properties(s.ctx, made.Reference(), []string{"summary", "resourcePool"}, &mvm); err != nil {
t.Fatalf("cannot read what was made: %v", err)
}
if mvm.Summary.Config.Template {
t.Error("what came out is another template, not a machine")
}
if mvm.ResourcePool == nil || *mvm.ResourcePool != target.pool {
t.Errorf("it landed in %v, not in the pool it was given (%v)", mvm.ResourcePool, target.pool)
}
if mvm.Summary.Runtime.PowerState != types.VirtualMachinePowerStatePoweredOff {
t.Errorf("it was started, though nothing asked for that: %s", mvm.Summary.Runtime.PowerState)
}
}
// Telling the guest what it is, against a server that holds real customisation
// specifications: gvm writes the two facts that are about this one machine into
// the one the vCenter keeps, and leaves the site's answers alone.
func TestSimCustomisationFromAVCenterSpec(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
names, err := specNames(s)
if err != nil {
t.Fatalf("specNames: %v", err)
}
if !contains(names, "vcsim-linux-static") || !contains(names, "vcsim-windows-static") {
t.Fatalf("the simulator's specifications are not what this test is written against: %v", names)
}
// A Linux specification with a netmask in it: both facts go in, and the
// netmask and gateway that came with it stay.
spec, err := customizationFor(s, deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor: %v", err)
}
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname is %q", got)
}
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") || !strings.Contains(got, "255.255.255.0") {
t.Errorf("the address line is %q", got)
}
// The machine's own name is the hostname unless something else is said.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-linux-static", hostname: "web05.fhi"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor with a hostname: %v", err)
}
if got := hostNameOf(spec); got != "web05.fhi" {
t.Errorf("--hostname was ignored: %q", got)
}
// Windows has its computer name somewhere else, and it is found there.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-windows-static"}, deploySource{name: "tpl"}, "WEB05")
if err != nil {
t.Fatalf("a Windows specification was refused: %v", err)
}
if got := hostNameOf(spec); got != "WEB05" {
t.Errorf("the Windows computer name is %q", got)
}
// The one whose adapter takes its address from DHCP has no netmask to give
// a fixed address, and says so rather than making one up.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05"); err == nil {
t.Error("an address was written into a DHCP specification")
}
// Without an address that same specification is perfectly usable.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux"}, deploySource{name: "tpl"}, "web05"); err != nil {
t.Errorf("a DHCP specification was refused with no address asked for: %v", err)
}
// A name that is not there says what is.
_, err = customizationFor(s, deployOpts{spec: "no-such-spec"}, deploySource{name: "tpl"}, "web05")
if err == nil {
t.Fatal("a specification that does not exist was accepted")
}
if !strings.Contains(err.Error(), "vcsim-linux-static") {
t.Errorf("it did not say what there is: %v", err)
}
// And the whole way through: a template, deployed with a customisation.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
opts := deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.56"}
task, err := startDeploy(s, src, target, "customised01", opts)
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
if _, err := s.vm("customised01"); err != nil {
t.Errorf("the customised machine cannot be found: %v", err)
}
}
// The estate screen against a server that answers: the hosts come back grouped
// under their clusters, what they carry is added up from the rows the list
// already holds, and Enter narrows the list to the host under the cursor.
+25 -6
View File
@@ -310,15 +310,34 @@ func confirm(question string, yes bool) (bool, error) {
func confirmDestructive(vc VCenter, headline string, facts [][2]string, consequence string, yes bool) (bool, error) {
P()
PF("%s %s\n", Crb("⚠ "), Cwb(headline))
P()
all := append([][2]string{{"vCenter", vc.Name + " " + vc.URL}, {"datacenter", vc.Datacenter}}, facts...)
for _, f := range all {
PF(" %-14s %s\n", f[0], f[1])
}
P()
printFacts(vc, facts)
for _, l := range wrap(consequence, 72) {
PF(" %s\n", Cr(l))
}
P()
return confirm("continue?", yes)
}
// confirmFacts is that page without the warning, for something that makes a
// thing rather than destroying one. Deploying a machine is not a decision to be
// talked out of in red; it is one to be shown the placement of first, because
// "a new machine appeared somewhere on the estate" is not an outcome anybody
// should get from a keystroke.
func confirmFacts(vc VCenter, headline string, facts [][2]string, yes bool) (bool, error) {
P()
PF("%s\n", Cwb(headline))
printFacts(vc, facts)
return confirm("continue?", yes)
}
// printFacts is the block both of them show: the server first, because the same
// machine name exists on more than one, then whatever this particular question
// is about.
func printFacts(vc VCenter, facts [][2]string) {
P()
all := append([][2]string{{"vCenter", vc.Name + " " + vc.URL}, {"datacenter", vc.Datacenter}}, facts...)
for _, f := range all {
PF(" %-14s %s\n", f[0], f[1])
}
P()
}
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.3.1