13 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 ae936420e0 [mike@mwxm4]
The address question comes up with "auto" in it.

A site with an address tool hands addresses out from it — that is what the tool
is configured for — so typing the word every time was asking somebody to
confirm the ordinary case by hand. It stands in the line now, and Enter is the
whole answer:

    address for web05, "auto" for one from dns, empty for DHCP: auto

Offered rather than made the meaning of the empty line. Every answer keeps the
meaning it had: erased back to nothing it is DHCP again, or whatever the chosen
specification says, and an address typed by hand is still typed by hand. Enter
commits nothing either — what is fetched here is handed straight back when the
deployment is not confirmed.

The prefilled line is input's own doing (inputWith), with the cursor at the end
of it, so it is a default that can be seen. One that cannot be seen is a
question answered by what was not typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:53:42 +02:00
Michael WesemannandClaude Opus 5 1aab0cf4a5 [mike@mwxm4]
Straight to the list, live, once a machine is being made.

Starting a clone left a sentence on the template's sheet saying that its TASK
column shows how far along it is and that ^l would make that move — three steps
of housekeeping after the one decision that mattered, on a screen which is a
page of facts about the machine being copied rather than about the one being
made.

Now the sheet goes away, the list comes back with the cursor still on the row
the task hangs off, and live mode turns itself on where it was off. The
percentage moves by itself, the new machine turns up in the table when it
exists, and the line under the table announces it.

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

Live is said in that line because it is a mode and it stays on: the title says
it is running and ^l ends it. It does not sweep at once the way ^l does — the
deployment re-read its own row a moment earlier — so the first tick is one
interval away, and that interval is already the busy one, because the row it
re-read is the one carrying the clone.

Putting the sheet away is a function of its own now: Esc and this both have to
forget the events that were read during the visit, and that reason belongs in
one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:41:38 +02:00
Michael WesemannandClaude Opus 5 94ebe679b7 [mike@mwxm4]
One dialect: -j, and nothing older than v2.5.0 is talked to.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured rather than assumed this time: `dns -s` on a name that is not there
prints "ERROR: host '...' not found" and exits 0. So the helper says what is
wrong in its output, not in its status, and gvm now reads it that way. The
first version believed the status, which took every refusal for an agreement
and — the other way about — made a free name look like an answer nobody could
parse. An appliance that cannot be reached is an error and not a free name:
concluding "nobody has this name" from a server that is down is the last thing
to do before asking it for an address.

A name the appliance already knows is refused before anything is added to it.
What `dns -a` would do with one is either refuse in worse words, or hang a
second address on somebody else's host record:

    web05 already exists (web05.fhi.mpg.de at 141.14.140.182) — give the
    machine another name, or free that one with 'dns -d web05'

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

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

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

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

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

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

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

* The record is read back rather than taken from the sentence the helper prints
  on its way past. "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'"
  is written for a person and gets reworded between versions; the record is the
  appliance's own answer, and where the two disagree the record is what the
  machine will actually have.
* It is fetched before the confirmation, so the question says the address
  rather than a promise of one.
* And given back where it is not used. A deployment abandoned at that question,
  or one the vCenter then refuses, releases it rather than leaving a record for
  a machine that was never made. That is what deployStep carries — the note,
  the record and the way to give it back travelling together, because a
  confirmation with eight arguments is one nobody can call correctly.

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

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

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

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

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

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

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

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

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

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