Author SHA1 Message Date
Michael WesemannandClaude Opus 5 97d8d4191d [mike@mwxm4]
The socket test's assertion was position-blind: strings.Contains(long, "0 or")
matches "10 or 12, not 11" as readily as the "0 or 4" it was looking for, so it
was correct only for the three inputs it happened to try. It now matches on the
position the counts occupy, and sweeps 2, 4 and 8 cores per socket against
every count from 1 to 20 rather than three cases at one topology.

Checked against a deliberately broken fix first: it reports "0 or 4, not 1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:43:29 +02:00
Michael WesemannandClaude Opus 5 606dc38411 [mike@mwxm4]
Five fixes to the resize feature, from a review of db2f019.

* openMenu discarded every warning it raised. It clears the status line as its
  last act — the menu is a fresh screen — which wiped the warning set on the
  way there before a single frame was drawn. Both warnings are now held and put
  back after the wipe; this also brings back the pre-existing "showing what was
  last read" warning, which had been dying the same way since before this
  feature.
* A machine whose configuration could not be read is now refused rather than
  silently allowed. sizeObjection claimed nothing for known=false, so the
  confirmation offered "0B → 8.0GB" and hotly() called a shrink a hot-add. Not
  knowing what a machine has is a reason to leave it alone: both entries grey
  out with the reason.
* The socket refusal no longer names 0 as a count that fits. below is 0
  whenever the wanted count is under one whole socket, so asking for 2 with 4
  cores per socket said "0 or 4, not 2" — offering a number parseSize itself
  refuses. Below one socket only the count above is named.
* `gvm size -c 8 -m 1026m` no longer sets the vCPUs and then refuses the
  memory. Both are parsed and checked before either is sent, which is the same
  answer power.go gives to two operations on one command line.
* run()'s locals no longer shadow the sizeCPUs and sizeMemory constants.

Tests: the socket refusal never offering 0, the unreadable configuration being
refused at both the menu and the change, and — against the simulator — that a
command line with one possible and one impossible change leaves the machine
exactly as it was. That last one was checked against the old code first: it
failed with "the vCPUs were changed anyway: 3, was 1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:39:40 +02:00
Michael WesemannandClaude Opus 5 db2f01908e [mike@mwxm4]
Changing what a machine has: vCPUs and memory, in the action menu as c and m,
and on the command line as `gvm size`.

resize.go holds the rules, in the shape power.go uses — one objection function
in two lengths, so the menu's grey-out and the message cannot disagree:

* A running machine can only grow, and only where it was built to: CPU and
  memory hot-add are per-machine settings. Memory can never shrink while it
  runs (vSphere has no memory hot-remove) and vCPUs only with hot-remove on.
* The vCPU count must be a multiple of the cores per socket. The socket
  topology is never changed to make a number fit — that would rewrite somebody's
  per-socket licence — so the two counts that do fit are named instead.
* Memory is typed in GB (512m for MB) and must be whole multiples of 4 MB.
* The configuration is read again immediately before anything is sent: the
  menu's grey-out only says a change of this kind is possible at all.

The four config fields are not in the sweep — config is the machine's whole
configuration document — so they are read for one machine when the menu opens
and again when the change is made.

Nothing here loses anything, so it gets the plain y/n question, not the page
that wants YES typed: that one stays for pulling the plug, reverting and
removing snapshots.

`gvm size --vm <machine>` with no number changes nothing and prints what the
machine has, including which of the three hot-plug settings it was built with —
the line that decides whether a change needs a maintenance window.

Tests: the objection matrix (off/running × grow/shrink × settings), the socket
rule, the pointer handling in sizingFrom, what parseSize takes and refuses, the
menu entries, and against the simulator a real ReconfigVM whose numbers are read
back from the server rather than assumed.

.gitignore gains .claude/, which is the agent harness's scratch directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:30:02 +02:00
13 changed files with 1160 additions and 22 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+68 -5
View File
@@ -97,6 +97,8 @@ not.
| `power -b <vm>` | ask the guest to reboot (needs VMware Tools) |
| `power --off <vm>` | power off at the hypervisor — hard |
| `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 |
| `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 |
@@ -372,12 +374,18 @@ this is about.
r revert to a snapshot ... s shut down the guest
d remove a snapshot ... b reboot the guest
D remove ALL snapshots S power off (hard)
B reset (hard)
──────────────────────────────── B reset (hard)
c change the vCPU count ...
m change the memory ...
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
always needs the shift key. What cannot be done right now is greyed out with the
reason next to it — "no VMware Tools", "already running", "no snapshots" — rather
than left out, and picking it anyway spells the reason out instead of running it.
Of the power pairs, lowercase asks the guest and uppercase acts at the
hypervisor: the violent variant always needs the shift key. What cannot be done
right now is greyed out with the reason next to it — "no VMware Tools", "already
running", "no snapshots", "needs it off" — rather than left out, and picking it
anyway spells the reason out instead of running it.
The three groups are what the machine has been, what it is, and what it is
doing.
Snapshots are drawn as the tree they are — which state descends from which is
the whole point of a snapshot list — and the one the machine is running from
@@ -455,6 +463,55 @@ things hold for all of them:
One power operation per command line; two is a mistake, not a sequence, and gvm
says so instead of guessing.
### What a machine has
`c` and `m` in the menu, `gvm size` on the command line: the vCPU count and the
memory. Nothing is lost by either, so they get the plain y/n question rather than
the page that wants YES typed out — a number set wrongly is set back.
vSphere refuses most of what one might ask for on a running machine, and gvm
refuses it first, with the reason, rather than sending it to be bounced:
* **A running machine can only grow, and only where it was built to.** CPU and
memory hot-add are per-machine settings, turned on when the machine was made.
Without them the entry says "needs it off". Memory can never shrink while a
machine runs — there is no hot-remove for it in vSphere at all — and vCPUs only
where hot-remove is on as well.
* **The socket topology is the machine's own.** A vCPU count has to be a multiple
of the cores per socket, and gvm does not quietly change the sockets to make a
number fit: software is licensed per socket, and a tool that turns 2 sockets
into 4 to accept an odd number would be writing somebody an invoice. It names
the two counts that do fit instead.
* **Memory is whole multiples of 4 MB.** It is typed in gigabytes, because that
is what the sheet shows and what anybody says out loud; `512m` means megabytes,
and `1.5g` is 1536 MB.
* **Read again immediately before it is sent.** The menu's grey-out says only
that a change of this kind is possible at all; between drawing it and answering
the question somebody else may have started the machine.
* **A machine whose configuration cannot be read is left alone.** Not knowing
what it has means there is nothing honest to put on the left of the arrow, so
both entries grey out and say so rather than offering a change from a figure
gvm does not have.
* **`gvm size -c 8 -m 1026m` does neither.** Both numbers are read and checked
before either is sent: a command line that sets the vCPUs and then refuses the
memory has half happened, which is the one outcome nobody asked for.
`gvm size --vm <machine>` on its own changes nothing and says what there is:
machine web01 (running)
vCPU 4, in 1 per socket
memory 8.0GB
while it runs add vCPUs, add memory
That last line is the one worth having before planning the work — it is the
difference between a change now and a maintenance window. Where a machine has
none of the three settings it says so plainly: "nothing — it has to be powered
off to be changed".
Hot-added resources are not necessarily in use the moment vCenter reports the
task done: the message says as much, because an operating system does not always
notice on its own that it has been given another four processors.
The printed listing (`vm -l`) is the same table: the same columns, the same
cells, the same colours, fitted to the terminal when there is one and written out
in full into a pipe, where the colours are left off.
@@ -716,6 +773,12 @@ 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
* 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
simulator — that a reconfigure actually lands, read back from the server
rather than assumed, and that a command line asking for one possible change
and one impossible one makes neither
* that a `^C` during a login does not take gvm with it, and that the login still
dies of it: the signal is caught for as long as the child has the screen, and
catching is not ignoring — an ignored one would be inherited by the ssh
+116 -4
View File
@@ -128,13 +128,21 @@ func (b *browser) openMenu() {
return
}
// Anything said on the way through this function is held until the end of
// it. The last thing openMenu does is clear the status line — the menu is a
// fresh screen and whatever was on the list behind it does not belong on it
// — and a warning set before that was being wiped before a single frame was
// drawn. A menu that is quietly less certain than it looks is worse than no
// menu: these two warnings are exactly the ones that say so.
var warn []string
// The row is re-read first. What the menu offers is decided from the
// machine's state, and a state from the last full sweep is old enough to
// matter: it would offer to power off a machine whose guest has meanwhile
// finished shutting down. A failure here is not fatal — the menu is simply
// built from what is known — but it is said.
if err := b.refreshRow(); err != nil {
b.setStatus(colWarn, "showing what was last read: "+err.Error())
warn = append(warn, "showing what was last read: "+err.Error())
}
r = b.current()
if r == nil {
@@ -147,12 +155,28 @@ func (b *browser) openMenu() {
return
}
b.menuSnaps = snaps
b.menu = b.buildMenu(*r, snaps)
// What the machine has, and what of it may be changed while it runs. It is
// not in the sweep — it is four fields of the whole configuration document
// — so it is read here, for this one machine, so that the two hardware
// entries can be greyed out with the reason rather than accepting a number
// vCenter is going to refuse. A failure is not fatal to the rest of the
// menu: those two entries grey themselves out, saying what could not be
// read, and the warning below says it again where it cannot be missed.
b.menuSize, err = sizingOf(r.sess, r.ref)
if err != nil {
warn = append(warn, err.Error())
}
b.menu = b.buildMenu(*r, snaps, b.menuSize)
// vmDetail asks nothing of the network; the snapshots are left out of it
// because the menu only wants the four lines above.
b.menuInfo = sheetPick(vmDetail(*r, nil, ""), menuFacts)
b.menuSel = 0
b.setStatus("", "")
if len(warn) > 0 {
b.setStatus(colWarn, strings.Join(warn, " · "))
}
}
func (b *browser) closeMenu() {
@@ -160,7 +184,7 @@ func (b *browser) closeMenu() {
}
// buildMenu is the menu for one machine in its current state.
func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
func (b *browser) buildMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
noSnaps, noSnapsHint := "", ""
if len(snaps) == 0 {
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
@@ -184,6 +208,12 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
{key: 'D', label: "remove ALL snapshots", why: noSnaps, hint: noSnapsHint,
run: func(b *browser, r vmRow) { b.removeAll(r, len(snaps)) }},
separator(),
// What the machine is, between what it has been and what it is doing.
// Both entries end in "..." for the same reason the snapshot ones do:
// they ask for something before anything happens.
sizeItem('c', "change the vCPU count ...", r, sz, sizeCPUs),
sizeItem('m', "change the memory ...", r, sz, sizeMemory),
separator(),
pwr('o', "power on", opPowerOn),
pwr('s', "shut down the guest", opShutdownGuest),
pwr('b', "reboot the guest", opRebootGuest),
@@ -193,6 +223,16 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
}
}
// sizeItem is one of the two hardware entries. What greys it out comes from
// sizeObjection asked its weaker question — not whether some particular number
// can be set, but whether any can right now — because the number has not been
// typed yet when the menu is drawn.
func sizeItem(key rune, label string, r vmRow, sz sizing, k sizeKind) menuItem {
short, long := sizeObjection(r, sz, k, 0)
return menuItem{key: key, label: label, why: long, hint: short,
run: func(b *browser, r vmRow) { b.resize(r, k) }}
}
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
// do the same for anyone who would rather read than remember.
func (b *browser) menuKey(k key) {
@@ -422,6 +462,78 @@ func (b *browser) power(r vmRow, op powerOp) {
b.done(msg)
}
// resize changes what the machine has: the value is typed, checked, confirmed
// and only then sent.
//
// The configuration is read again here rather than taken from the menu that was
// just drawn. It is the same read, a moment later, and the moment matters: the
// menu's grey-out only says a change of this kind is possible at all, and
// between drawing it and answering the question somebody else may have started
// the machine — which turns "give it 4 GB less" from a reconfigure into
// something vSphere will not do.
//
// The question at the end is the plain one, not the page that asks for YES to be
// typed. That page is for losing something: pulling the plug, reverting,
// removing snapshots. Nothing here is lost — a number set wrongly is set back —
// and a confirmation asked for everything is a confirmation nobody reads.
func (b *browser) resize(r vmRow, k sizeKind) {
b.closeMenu()
if r.sess == nil {
b.setStatus(colErr, "no connection to "+r.vc.Name)
return
}
sz, err := sizingOf(r.sess, r.ref)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
if err := checkSize(r, sz, k, 0); err != nil {
b.setStatus(colWarn, err.Error())
return
}
typed, ok := b.input(sizePrompt(r, sz, k))
if !ok || typed == "" {
b.setStatus(colDim, "nothing done")
return
}
want, err := parseSize(k, typed)
if err != nil {
b.setStatus(colWarn, err.Error())
return
}
if err := checkSize(r, sz, k, want); err != nil {
b.setStatus(colWarn, err.Error())
return
}
from, to := k.shown(sz.now(k)), k.shown(want)
if !b.ask(SF("%s on %s: %s → %s?", r.name, r.vc.Name, from, to)) {
b.setStatus(colDim, "nothing done")
return
}
b.working(SF("%s: %s → %s ...", r.name, from, to))
msg, err := runResize(r.sess, r, sz, k, want)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.done(msg)
}
// sizePrompt is the label on that line. It carries the current value, so the
// number being replaced is in front of the person replacing it, and for memory
// the unit as well — a field that takes 16 and means gigabytes has to say so
// where it is typed, not in a manual.
func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
if k == sizeCPUs {
return SF("vCPUs for %s (now %d): ", r.name, sz.cpus)
}
return SF("memory for %s in GB (now %s, or 512m): ", r.name, k.shown(sz.memoryMB))
}
// 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.
@@ -603,7 +715,7 @@ func (b *browser) renderMenu() {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"lowercase asks the guest, uppercase the hypervisor"})
"of the power pairs, lowercase asks the guest and uppercase the hypervisor"})
}
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
b.write(sb.String())
+50 -9
View File
@@ -137,7 +137,7 @@ func TestMenuAvailability(t *testing.T) {
}
someSnaps := []snapEntry{{name: "s1", created: "01.01.2026 00:00"}}
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps)
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps, testSizing())
for _, k := range []rune{'n', 'r', 'd', 'D', 's', 'b', 'S', 'B'} {
if !find(running, k).available() {
t.Errorf("%q not offered for a running machine with Tools: %s", string(k), find(running, k).why)
@@ -147,7 +147,7 @@ func TestMenuAvailability(t *testing.T) {
t.Error("power on is offered for a machine that is already running")
}
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps)
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps, testSizing())
for _, k := range []rune{'s', 'b'} {
if find(noTools, k).available() {
t.Errorf("%q offered without VMware Tools", string(k))
@@ -159,7 +159,7 @@ func TestMenuAvailability(t *testing.T) {
}
}
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps)
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps, testSizing())
if !find(stopped, 'o').available() {
t.Error("power on is not offered for a stopped machine")
}
@@ -170,7 +170,7 @@ func TestMenuAvailability(t *testing.T) {
}
// Without snapshots there is nothing to revert to or remove.
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil)
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil, testSizing())
for _, k := range []rune{'r', 'd', 'D'} {
if find(bare, k).available() {
t.Errorf("%q offered for a machine with no snapshots", string(k))
@@ -395,7 +395,7 @@ func TestMenuHintAndReasonAgree(t *testing.T) {
stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown),
stateRow(types.VirtualMachinePowerStateSuspended, toolsUp),
} {
for _, m := range b.buildMenu(r, nil) {
for _, m := range b.buildMenu(r, nil, testSizing()) {
if m.isSeparator() {
continue
}
@@ -606,15 +606,15 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
wantFacts bool
}{
{"30", true}, // room for both, all four facts
{"19", true}, // room for three of them, the hostname among them
{"18", false}, // room for two: the state and the address
{"13", false}, // no room at all, so the choices have the screen
{"22", true}, // room for three of them, the hostname among them
{"21", false}, // room for two: the state and the address
{"16", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
b.menu = b.buildMenu(r, nil)
b.menu = b.buildMenu(r, nil, testSizing())
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
@@ -761,3 +761,44 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
t.Error("no task wait found at all — this check has stopped checking anything")
}
}
// A menu is allowed to be built from less than it wanted, but never to look as
// certain as one that was. openMenu clears the status line as its last act —
// the menu is a fresh screen — and a warning set on the way there was being
// wiped before a single frame was drawn.
func TestTheMenuKeepsWhatItHadToWarnAbout(t *testing.T) {
// A row with no session: the configuration cannot be read, which is the
// warning this is about.
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
// openMenu leaves early without a session at all, so the warning is checked
// where it is raised: a sizing that could not be read must both grey the
// entries out and leave something on the status line.
if _, err := sizingOf(nil, r.ref); err == nil {
t.Error("a machine with no connection read its configuration anyway")
}
menu := b.buildMenu(r, nil, sizing{})
for _, k := range []rune{'c', 'm'} {
for _, m := range menu {
if m.key != k {
continue
}
if m.available() {
t.Errorf("%q is offered on a machine whose configuration is unknown", string(k))
}
if m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
}
}
// testSizing is a plausible machine's hardware for the menu tests: four vCPUs
// in one socket, eight gigabytes, and none of the three hot-plug settings — the
// way most machines are actually built, and the case where the two hardware
// entries are only offered on a machine that is switched off.
func testSizing() sizing {
return sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
}
+1
View File
@@ -742,6 +742,7 @@ type browser struct {
// underneath without having to remember where it came from.
menu []menuItem
menuSnaps []snapEntry
menuSize sizing // what the machine has, for the two hardware entries
menuInfo []sheetLine // the few lines of the sheet shown above the choices
menuSel int
pick *picker
+1 -1
View File
@@ -1463,7 +1463,7 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
}
}
b.menu = b.buildMenu(*b.current(), b.current().snaps)
b.menu = b.buildMenu(*b.current(), b.current().snaps, testSizing())
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
for _, want := range []string{"take a snapshot", "power off", "reset"} {
+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"}
"--off", "--reset", "--vm"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
+1 -1
View File
@@ -282,7 +282,7 @@ func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil) {
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.isSeparator() {
continue
}
+25
View File
@@ -131,6 +131,20 @@ func run() error {
subPower.String(&pwOff, "", "off", "Power off <vm> at the hypervisor — hard, like pulling the plug")
subPower.String(&pwReset, "", "reset", "Reset <vm> at the hypervisor — hard, like the reset button")
// The machine is a flag of its own here, not the value of the action flag as
// it is for power and snap: this command takes a machine *and* a number, and
// only one of the two can be the value of "--cpus".
// szCPUs and szMemory, not sizeCPUs and sizeMemory: those two are the
// sizeKind constants, and locals of the same name would shadow them for the
// rest of this function — where the next person to write parseSize(sizeCPUs,
// ...) would get a type error with no obvious cause.
var szVM, szCPUs, szMemory string
subSize := flaggy.NewSubcommand("size")
subSize.Description = "Show or change a machine's vCPUs and memory"
subSize.String(&szVM, "", "vm", "The machine to show or change")
subSize.String(&szCPUs, "c", "cpus", "Set its vCPU count")
subSize.String(&szMemory, "m", "memory", "Set its memory, in GB (or 512m for MB)")
var hostCount, hostTelemetry bool
subHost := flaggy.NewSubcommand("host")
subHost.Description = "Host commands"
@@ -158,6 +172,7 @@ func run() error {
flaggy.AttachSubcommand(subVM, 1)
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
@@ -265,6 +280,16 @@ func run() error {
return errf("one power operation at a time, not %d", len(given))
}
case subSize.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
if szVM == "" {
return errf("size needs a machine: gvm size --vm <machine> [-c <vcpus>] [-m <memory>]")
}
return sizeCLI(vc, szVM, szCPUs, szMemory, yes)
case subHost.Used:
vc, err := cfg.pick(vcname)
if err != nil {
+460
View File
@@ -0,0 +1,460 @@
// resize.go — changing what a machine has: vCPUs and memory.
//
// This is the third kind of thing gvm does to a machine, after its power and
// its snapshots, and it is the one with the most ways to be refused. vSphere
// will not take just any number:
//
// 1. A running machine can only grow, and only where it was built to. CPU and
// memory hot-add are per-machine settings somebody turned on when the
// machine was made; without them the machine has to be powered off first.
// Memory can never shrink while it runs — there is no hot-remove for it at
// all — and vCPUs only where hot-remove is on as well.
// 2. The vCPU count has to be a multiple of the cores per socket. gvm does not
// quietly change the socket topology to make a number fit: software is
// licensed per socket, and a tool that turns 2 sockets into 4 to satisfy an
// odd vCPU count would be writing somebody an invoice.
// 3. Memory is a whole number of megabytes, in multiples of four.
//
// All of that is asked before anything is sent, in the same shape the power
// operations use: an objection in two lengths, one short enough for the menu's
// own column and one to be read on its own, both out of one function so the
// menu and the message cannot disagree.
package main
import (
"strconv"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// reconfigWait is how long gvm watches a reconfigure before it stops watching.
// The task itself is usually over in a second — the machine is not copied or
// moved, only its configuration is written — so this is a limit on a frozen
// terminal, not on the operation. Like every other wait here, vCenter carries
// on regardless.
const reconfigWait = 5 * time.Minute
// sizeKind is which of the two is being changed. They are separate operations
// with separate rules, not two fields of one: memory can never shrink while a
// machine runs and vCPUs sometimes can, and a menu entry that greys out for
// both reasons at once could not say why.
type sizeKind int
const (
sizeCPUs sizeKind = iota
sizeMemory
)
func (k sizeKind) what() string {
if k == sizeCPUs {
return "vCPU count"
}
return "memory"
}
// sizing is what a machine has and what may be changed while it runs. It comes
// from `config`, which the inventory sweep deliberately does not read — it is
// the whole configuration of a machine, and this wants four fields of it — so
// it is asked for one machine at a time, when somebody is about to change it.
type sizing struct {
cpus int32
coresPerSocket int32
memoryMB int32
cpuHotAdd bool
cpuHotRemove bool
memoryHotAdd bool
known bool // false when the configuration could not be read at all
}
// sizingProps are those four fields and the two figures they are about. Named
// paths rather than "config": the whole configuration of a machine is a large
// document, and this is a menu being drawn.
var sizingProps = []string{
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.cpuHotAddEnabled",
"config.cpuHotRemoveEnabled",
"config.memoryHotAddEnabled",
}
// sizingOf reads them. A machine whose configuration cannot be read gets a
// sizing that says it knows nothing, and sizeObjection turns that into a
// refusal of its own: not knowing what a machine has is a reason to leave it
// alone, not a reason to send a number and hope.
func sizingOf(s *session, ref types.ManagedObjectReference) (sizing, error) {
if s == nil {
return sizing{}, errf("no connection to read the configuration over")
}
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, sizingProps, &mvm); err != nil {
return sizing{}, errf("%s: cannot read the configuration of %s: %w",
s.vc.Name, vmName(s, ref), err)
}
return sizingFrom(mvm.Config), nil
}
// sizingFrom is the same from the property itself, so the rules below can be
// exercised without a server.
func sizingFrom(cfg *types.VirtualMachineConfigInfo) sizing {
if cfg == nil {
return sizing{}
}
sz := sizing{
cpus: cfg.Hardware.NumCPU,
memoryMB: cfg.Hardware.MemoryMB,
known: true,
}
// The cores per socket and the three flags are pointers: unset means the
// machine predates the setting or vCenter did not send it. Unset is one
// core per socket, which divides into everything, and off.
sz.coresPerSocket = 1
if cfg.Hardware.NumCoresPerSocket != nil {
sz.coresPerSocket = *cfg.Hardware.NumCoresPerSocket
}
if cfg.CpuHotAddEnabled != nil {
sz.cpuHotAdd = *cfg.CpuHotAddEnabled
}
if cfg.CpuHotRemoveEnabled != nil {
sz.cpuHotRemove = *cfg.CpuHotRemoveEnabled
}
if cfg.MemoryHotAddEnabled != nil {
sz.memoryHotAdd = *cfg.MemoryHotAddEnabled
}
return sz
}
// now is the current value of whichever of the two this is, and shown is it in
// the words the sheet uses — "4 vCPU", "8.0GB" — so a message about a change
// and the line above it read as the same figure.
func (sz sizing) now(k sizeKind) int32 {
if k == sizeCPUs {
return sz.cpus
}
return sz.memoryMB
}
func (k sizeKind) shown(v int32) string {
if k == sizeCPUs {
return SF("%d vCPU", v)
}
return units.ByteSize(int64(v) * 1024 * 1024).String()
}
// ------------------------------------------------------------- what was typed
// parseSize turns what somebody typed into the number vSphere wants: a vCPU
// count, or a memory size in megabytes.
//
// Memory is read as gigabytes, because that is the unit the sheet shows and the
// unit anybody says out loud — "give it 16" is never sixteen megabytes. An
// explicit unit overrides that, so 512m is still sayable, and a fraction is
// taken where it lands on a whole megabyte: 1.5g is 1536 MB.
func parseSize(k sizeKind, text string) (int32, error) {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return 0, errf("nothing typed")
}
if k == sizeCPUs {
n, err := strconv.Atoi(t)
if err != nil {
return 0, errf("%q is not a number of vCPUs", text)
}
if n < 1 {
return 0, errf("a machine has at least one vCPU, not %d", n)
}
if n > maxCPUs {
return 0, errf("%d vCPUs is past anything vSphere builds — a typo?", n)
}
return int32(n), nil
}
unit := "g"
for _, suffix := range []string{"mb", "gb", "m", "g"} {
if strings.HasSuffix(t, suffix) {
unit, t = suffix[:1], strings.TrimSpace(strings.TrimSuffix(t, suffix))
break
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, errf("%q is not an amount of memory", text)
}
mb := v
if unit == "g" {
mb = v * 1024
}
if mb < 4 {
return 0, errf("%s is less memory than a machine can have", text)
}
if mb > maxMemoryMB {
return 0, errf("%s is more memory than vSphere takes — a typo?", text)
}
if mb != float64(int64(mb)) {
return 0, errf("%s is not a whole number of megabytes", text)
}
return int32(mb), nil
}
// The two ceilings are not vSphere's exact maxima, which move with every
// release and with the hardware version of the machine. They are there to catch
// a finger that stayed on a key: past these, a number is a typo rather than an
// intention, and everything below them is left to the server to accept or
// refuse with its own reasons.
const (
maxCPUs = 1024
maxMemoryMB = 32 * 1024 * 1024 // 32 TB
)
// -------------------------------------------------------------- the objection
// sizeObjection says why this machine cannot be given that, in two lengths, the
// same as powerObjection. Both the menu and the message come from here.
//
// A want of 0 asks the weaker question the menu asks while it is being drawn:
// not "can it have six" but "is there any number at all it could be given right
// now" — which is what decides whether the entry is offered or greyed out.
func sizeObjection(r vmRow, sz sizing, k sizeKind, want int32) (short, long string) {
// A machine whose configuration could not be read is not one to change. It
// is tempting to send it anyway and let vCenter be the authority on what it
// takes — but gvm would not know what the machine has now, so it could
// neither check the socket rule nor put an honest "from" in the question it
// asks. "8.0GB, up from something I could not read" is not a confirmation.
if !sz.known {
return "configuration unread", SF("what %s has could not be read, so there is nothing "+
"to change it from — try again, or change it in the vSphere client", r.name)
}
running := r.running()
if want == 0 {
switch {
case !running:
return "", ""
case k == sizeCPUs && !sz.cpuHotAdd && !sz.cpuHotRemove:
return "needs it off", SF("%s is running and has neither CPU hot-add nor hot-remove — "+
"power it off to change the vCPU count", r.name)
case k == sizeMemory && !sz.memoryHotAdd:
return "needs it off", SF("%s is running and has no memory hot-add — "+
"power it off to change its memory", r.name)
}
return "", ""
}
now := sz.now(k)
if want == now {
return "unchanged", SF("%s already has %s", r.name, k.shown(now))
}
if k == sizeCPUs {
// The socket topology is the machine's, not gvm's to adjust: a vCPU
// count that does not divide into it is refused with the two counts
// that do, rather than made to fit by changing the number of sockets.
if per := sz.coresPerSocket; per > 1 && want%per != 0 {
below, above := want-want%per, want-want%per+per
// Below a single socket there is no lower count to offer: zero
// processors is not a machine, and parseSize refuses it anyway.
// Naming it would be offering an answer gvm will not take.
fits := SF("%d or %d", below, above)
if below < per {
fits = SF("%d", above)
}
return "not a whole socket", SF("%s has %d cores per socket, so its vCPUs come in "+
"multiples of %d — %s, not %d", r.name, per, per, fits, want)
}
switch {
case running && want > now && !sz.cpuHotAdd:
return "no CPU hot-add", SF("%s is running and CPU hot-add is off — "+
"power it off to give it more than %d vCPU", r.name, now)
case running && want < now && !sz.cpuHotRemove:
return "no CPU hot-remove", SF("%s is running and CPU hot-remove is off — "+
"power it off to take vCPUs away from it", r.name)
}
return "", ""
}
switch {
case want%4 != 0:
return "not a multiple of 4 MB", SF("memory is set in multiples of 4 MB, and %d MB is not one", want)
case running && want < now:
// Not a flag anybody can turn on: vSphere has no memory hot-remove.
return "cannot shrink while on", SF("%s is running, and memory can never be taken away from "+
"a running machine — power it off first", r.name)
case running && !sz.memoryHotAdd:
return "no memory hot-add", SF("%s is running and memory hot-add is off — "+
"power it off to change its memory", r.name)
}
return "", ""
}
// checkSize is that objection as an error, or nil. Asked before the change is
// offered and again immediately before it is sent: between a menu being drawn
// and a confirmation being answered, somebody else may have powered the machine
// on.
func checkSize(r vmRow, sz sizing, k sizeKind, want int32) error {
if _, long := sizeObjection(r, sz, k, want); long != "" {
return errf("%s", long)
}
return nil
}
// hotly reports whether this change is going to happen underneath a running
// guest, which is worth saying afterwards: an operating system does not
// necessarily notice on its own that it has been given another four processors.
func hotly(r vmRow, k sizeKind, sz sizing, want int32) bool {
return r.running() && want > sz.now(k)
}
// ------------------------------------------------------------------ doing it
// runResize sends the change and waits for it. One task per call even when both
// numbers move, because the two are separate operations everywhere else in gvm
// and a single message that half worked would be the worst of both.
func runResize(s *session, r vmRow, sz sizing, k sizeKind, want int32) (message string, err error) {
if err := checkSize(r, sz, k, want); err != nil {
return "", err
}
spec := types.VirtualMachineConfigSpec{}
if k == sizeCPUs {
spec.NumCPUs = want
} else {
spec.MemoryMB = int64(want)
}
vm := object.NewVirtualMachine(s.client.Client, r.ref)
task, err := vm.Reconfigure(s.ctx, spec)
if err != nil {
return "", errf("%s: cannot change the %s of %s: %w", s.vc.Name, k.what(), r.name, err)
}
what := SF("%s of %s", k.what(), r.name)
if err := waitTask(s.ctx, task, reconfigWait, what); err != nil {
return "", err
}
msg := SF("%s: %s → %s", r.name, k.shown(sz.now(k)), k.shown(want))
if hotly(r, k, sz, want) {
msg += " — added while it runs; the guest may have to bring it online"
}
return msg, nil
}
// ---------------------------------------------------------- the command line
// sizeCLI is `gvm size`: with no number it says what the machine has, and with
// one it changes it. The two live in one command because "what has it got" is
// the question one asks immediately before "give it more", and having to
// remember two spellings of the same noun to ask both is a small tax.
func sizeCLI(vc VCenter, vmname, cpus, memory string, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
vm, err := s.vm(vmname)
if err != nil {
return err
}
// Read as fresh as the operation is going to be: what is offered and what is
// refused both depend on whether the machine is running right now.
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), append([]string{"summary", "guest"}, sizingProps...), &mvm); err != nil {
return errf("%s: cannot read %s: %w", vc.Name, vm.Name(), err)
}
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: vm.Name(), vm: mvm}
sz := sizingFrom(mvm.Config)
if cpus == "" && memory == "" {
sizeShow(r, sz)
return nil
}
// Both are read and checked before either is sent. A command line that sets
// the vCPUs and then refuses the memory for not being a multiple of 4 MB has
// half happened, and half of what somebody asked for is the one outcome
// nobody wanted — it is why `power` refuses two operations at once rather
// than carrying out the first.
//
// Checking both against the machine as it is now is sound because neither
// change can make the other impossible: what a resize is refused for is the
// power state, the hot-plug settings and the socket topology, and none of
// the three is touched here.
var changes []struct {
kind sizeKind
want int32
}
for _, c := range []struct {
kind sizeKind
text string
}{{sizeCPUs, cpus}, {sizeMemory, memory}} {
if c.text == "" {
continue
}
want, err := parseSize(c.kind, c.text)
if err != nil {
return err
}
if err := checkSize(r, sz, c.kind, want); err != nil {
return err
}
changes = append(changes, struct {
kind sizeKind
want int32
}{c.kind, want})
}
for _, c := range changes {
ok, err := confirm(SF("%s on %s: %s → %s?", r.name, vc.Name,
c.kind.shown(sz.now(c.kind)), c.kind.shown(c.want)), yes)
if err != nil || !ok {
return err
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
return err
}
PO(msg)
}
return nil
}
// sizeShow prints what the machine has and what could be changed without
// stopping it — the second being the thing one actually wants to know before
// planning the work, and the thing no listing anywhere else says.
func sizeShow(r vmRow, sz sizing) {
if !sz.known {
PE("the configuration of " + r.name + " could not be read")
return
}
PF("%-14s %s\n", "machine", r.name+" ("+r.powerLong()+")")
PF("%-14s %s\n", "vCPU", SF("%d, in %d per socket", sz.cpus, sz.coresPerSocket))
PF("%-14s %s\n", "memory", sizeMemory.shown(sz.memoryMB))
PF("%-14s %s\n", "while it runs", liveChanges(sz))
}
// liveChanges is that last line: which of the three hot-plug settings this
// machine was built with, said as what they let one do rather than as the names
// of the flags.
func liveChanges(sz sizing) string {
var can []string
if sz.cpuHotAdd {
can = append(can, "add vCPUs")
}
if sz.cpuHotRemove {
can = append(can, "remove vCPUs")
}
if sz.memoryHotAdd {
can = append(can, "add memory")
}
if len(can) == 0 {
return "nothing — it has to be powered off to be changed"
}
return strings.Join(can, ", ")
}
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sizedRow is a machine in a given power state, for the rules below. Only the
// power state is read off it: everything else a resize depends on comes from
// the sizing, which is read separately from the machine's configuration.
func sizedRow(state types.VirtualMachinePowerState) vmRow {
return vmRow{
name: "web01",
vc: VCenter{Name: "v308"},
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Runtime: types.VirtualMachineRuntimeInfo{PowerState: state},
}},
}
}
// What somebody types has to become the number vSphere wants, or a refusal that
// says which part of it was not a number.
func TestParseSize(t *testing.T) {
for _, c := range []struct {
kind sizeKind
text string
want int32
bad string // a piece of the error, when it is meant to be refused
}{
{kind: sizeCPUs, text: "4", want: 4},
{kind: sizeCPUs, text: " 16 ", want: 16},
{kind: sizeCPUs, text: "0", bad: "at least one"},
{kind: sizeCPUs, text: "-2", bad: "at least one"},
{kind: sizeCPUs, text: "4.5", bad: "not a number"},
{kind: sizeCPUs, text: "eight", bad: "not a number"},
{kind: sizeCPUs, text: "99999", bad: "typo"},
// Memory is gigabytes unless it says otherwise: nobody means 16 MB.
{kind: sizeMemory, text: "16", want: 16384},
{kind: sizeMemory, text: "8g", want: 8192},
{kind: sizeMemory, text: "8GB", want: 8192},
{kind: sizeMemory, text: "512m", want: 512},
{kind: sizeMemory, text: "512mb", want: 512},
{kind: sizeMemory, text: "1.5g", want: 1536},
{kind: sizeMemory, text: "0", bad: "less memory"},
{kind: sizeMemory, text: "0.0001g", bad: "less memory"},
{kind: sizeMemory, text: "4.5m", bad: "whole number"},
{kind: sizeMemory, text: "lots", bad: "not an amount"},
{kind: sizeMemory, text: "99999g", bad: "typo"},
} {
got, err := parseSize(c.kind, c.text)
switch {
case c.bad != "":
if err == nil {
t.Errorf("parseSize(%q) = %d, want a refusal", c.text, got)
} else if !strings.Contains(err.Error(), c.bad) {
t.Errorf("parseSize(%q) refused with %q, which does not mention %q", c.text, err, c.bad)
}
case err != nil:
t.Errorf("parseSize(%q): %v", c.text, err)
case got != c.want:
t.Errorf("parseSize(%q) = %d, want %d", c.text, got, c.want)
}
}
}
// The rules a running machine is held to. This is the heart of the feature: what
// vSphere will refuse has to be refused here first, with the reason, rather than
// sent and bounced with a stack of SOAP.
func TestSizeObjectionOnARunningMachine(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true,
cpuHotAdd: true, cpuHotRemove: true, memoryHotAdd: true}
for _, c := range []struct {
what string
row vmRow
sz sizing
kind sizeKind
want int32
bad string // a piece of the objection, or "" when it must be allowed
}{
// Powered off, anything goes.
{"more vCPUs, machine off", off, plain, sizeCPUs, 8, ""},
{"fewer vCPUs, machine off", off, plain, sizeCPUs, 2, ""},
{"more memory, machine off", off, plain, sizeMemory, 16384, ""},
{"less memory, machine off", off, plain, sizeMemory, 4096, ""},
// Running, without the settings that would allow it.
{"more vCPUs, no hot-add", on, plain, sizeCPUs, 8, "hot-add is off"},
{"fewer vCPUs, no hot-remove", on, plain, sizeCPUs, 2, "hot-remove is off"},
{"more memory, no hot-add", on, plain, sizeMemory, 16384, "hot-add is off"},
// Running, with them.
{"more vCPUs, hot-add on", on, hot, sizeCPUs, 8, ""},
{"fewer vCPUs, hot-remove on", on, hot, sizeCPUs, 2, ""},
{"more memory, hot-add on", on, hot, sizeMemory, 16384, ""},
// The one no setting can allow: vSphere has no memory hot-remove.
{"less memory, hot-add on", on, hot, sizeMemory, 4096, "never be taken away"},
// Nothing to do.
{"the same vCPUs", on, hot, sizeCPUs, 4, "already"},
{"the same memory", on, hot, sizeMemory, 8192, "already"},
// Memory is whole multiples of four megabytes.
{"memory of 10 MB", off, plain, sizeMemory, 10, "multiples of 4 MB"},
// Not knowing what a machine has is a reason to leave it alone: there
// would be nothing honest to put on the left of the arrow.
{"unknown configuration", on, sizing{}, sizeCPUs, 64, "could not be read"},
{"unknown configuration, menu time", on, sizing{}, sizeMemory, 0, "could not be read"},
} {
short, long := sizeObjection(c.row, c.sz, c.kind, c.want)
switch {
case c.bad == "":
if long != "" {
t.Errorf("%s: refused with %q", c.what, long)
}
case long == "":
t.Errorf("%s: allowed, want a refusal mentioning %q", c.what, c.bad)
case !strings.Contains(long, c.bad):
t.Errorf("%s: refused with %q, which does not mention %q", c.what, long, c.bad)
}
// Both lengths or neither: the menu column and the message come from
// here together, and one without the other greys an entry out with
// nothing to explain it.
if (short == "") != (long == "") {
t.Errorf("%s: short %q and long %q disagree about whether there is an objection",
c.what, short, long)
}
}
}
// The socket topology is the machine's own. A vCPU count that does not divide
// into it is refused with the two that do, rather than made to fit by changing
// the number of sockets underneath somebody's per-socket licence.
func TestSizeKeepsTheSocketTopology(t *testing.T) {
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
sz := sizing{cpus: 8, coresPerSocket: 4, memoryMB: 8192, known: true}
_, long := sizeObjection(off, sz, sizeCPUs, 6)
if long == "" {
t.Fatal("6 vCPUs was allowed on a machine with 4 cores per socket")
}
for _, want := range []string{"4 cores per socket", "4 or 8"} {
if !strings.Contains(long, want) {
t.Errorf("the refusal does not say %q: %q", want, long)
}
}
if _, long := sizeObjection(off, sz, sizeCPUs, 12); long != "" {
t.Errorf("12 vCPUs is three whole sockets and was refused: %q", long)
}
// Below one whole socket there is no lower count to offer. Naming 0 would
// be offering an answer parseSize itself refuses.
for _, want := range []int32{1, 2, 3} {
_, long := sizeObjection(off, sz, sizeCPUs, want)
if long == "" {
t.Errorf("%d vCPUs was allowed with 4 cores per socket", want)
continue
}
if !strings.Contains(long, "— 4, not") {
t.Errorf("asking for %d does not offer 4 on its own: %q", want, long)
}
}
// The same across the topologies machines are actually built with. The
// counts sit after the dash, so that is where a zero is looked for:
// "0 or 4" would be the bug and "10 or 12" is a perfectly good answer, and
// a test that cannot tell them apart fails the day this loop is widened.
for _, per := range []int32{2, 4, 8} {
sz := sizing{cpus: 8, coresPerSocket: per, memoryMB: 8192, known: true}
for want := int32(1); want <= 20; want++ {
_, long := sizeObjection(off, sz, sizeCPUs, want)
switch {
case want%per == 0:
if long != "" && !strings.Contains(long, "already") {
t.Errorf("%d vCPUs is whole sockets of %d and was refused: %q", want, per, long)
}
case long == "":
t.Errorf("%d vCPUs was allowed with %d cores per socket", want, per)
case strings.Contains(long, "— 0"):
t.Errorf("%d vCPUs with %d per socket offers none at all: %q", want, per, long)
}
}
}
}
// A machine whose configuration could not be read is refused rather than sent:
// gvm would not know what it has, so it could put nothing honest on the left of
// the arrow — "0B → 8.0GB" is not a confirmation, it is a wrong number.
func TestAnUnreadableConfigurationIsRefused(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
for _, k := range []sizeKind{sizeCPUs, sizeMemory} {
if err := checkSize(on, sizing{}, k, 8); err == nil {
t.Errorf("the %s was changed on a machine whose configuration is unknown", k.what())
}
// And the menu says the same thing before any number is typed.
short, long := sizeObjection(on, sizing{}, k, 0)
if short == "" || long == "" {
t.Errorf("the %s entry is offered on a machine whose configuration is unknown", k.what())
}
}
}
// A machine whose configuration says nothing about the hot-plug settings is a
// machine without them, and one core per socket divides into everything — the
// fields are pointers, and unset must not read as zero cores per socket.
func TestSizingFromAnEmptyConfiguration(t *testing.T) {
if sz := sizingFrom(nil); sz.known {
t.Error("a machine with no configuration claimed to know its sizing")
}
sz := sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 4, MemoryMB: 8192},
})
if !sz.known {
t.Fatal("a configuration that was read is said to be unknown")
}
if sz.coresPerSocket != 1 {
t.Errorf("cores per socket = %d, want 1 where the server did not say", sz.coresPerSocket)
}
if sz.cpuHotAdd || sz.cpuHotRemove || sz.memoryHotAdd {
t.Error("a setting the server did not send was taken for on")
}
yes := true
four := int32(4)
sz = sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 8, MemoryMB: 16384, NumCoresPerSocket: &four},
CpuHotAddEnabled: &yes,
MemoryHotAddEnabled: &yes,
})
if sz.coresPerSocket != 4 || !sz.cpuHotAdd || !sz.memoryHotAdd || sz.cpuHotRemove {
t.Errorf("the settings did not come through: %+v", sz)
}
}
// The two entries are greyed out with the reason on a machine that cannot take
// the change at all, and offered on one that can — which is the weaker question
// the menu asks, before any number has been typed.
func TestTheMenuOffersTheHardwareEntries(t *testing.T) {
b := &browser{}
find := func(items []menuItem, k rune) menuItem {
t.Helper()
for _, m := range items {
if m.key == k {
return m
}
}
t.Fatalf("no menu entry %q", string(k))
return menuItem{}
}
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := plain
hot.cpuHotAdd, hot.memoryHotAdd = true, true
running := b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, plain)
for _, k := range []rune{'c', 'm'} {
m := find(running, k)
if m.available() {
t.Errorf("%q is offered on a running machine with no hot-plug", string(k))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
for _, c := range []struct {
what string
menu []menuItem
}{
{"a machine that is off", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOff), nil, plain)},
{"a running machine with hot-add", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, hot)},
} {
for _, k := range []rune{'c', 'm'} {
if m := find(c.menu, k); !m.available() {
t.Errorf("%q is not offered on %s: %s", string(k), c.what, m.why)
}
}
}
}
// What the messages call the two figures is what the sheet calls them, so a
// change and the line it changes read as the same number.
func TestSizeIsShownTheWayTheSheetShowsIt(t *testing.T) {
if got := sizeCPUs.shown(4); got != "4 vCPU" {
t.Errorf("vCPUs shown as %q", got)
}
if got := sizeMemory.shown(8192); got != "8.0GB" {
t.Errorf("memory shown as %q, want the sheet's own spelling", got)
}
r := vmRow{vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{MemorySizeMB: 8192}}}}
if sheet, msg := r.memory(), sizeMemory.shown(8192); sheet != msg {
t.Errorf("the sheet says %q and a resize says %q", sheet, msg)
}
}
+128
View File
@@ -1218,6 +1218,134 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
}
}
// Changing what a machine has, against a server that answers: the refusal on a
// running machine, and the change itself once it is off. The numbers are read
// back from the vCenter afterwards rather than assumed — a reconfigure that is
// accepted and does nothing would otherwise look exactly like one that worked.
func TestSimResizeAMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
sz, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
if !sz.known {
t.Fatal("the machine's configuration came back empty")
}
// The simulator's machines run, and are built without hot-plug — which is
// the case the objection exists for.
if !r.running() {
t.Fatal("the simulated machine is not running, so there is nothing to refuse")
}
if err := checkSize(r, sz, sizeCPUs, sz.cpus+2); err == nil {
t.Error("more vCPUs were allowed on a running machine with no hot-add")
}
if err := checkSize(r, sz, sizeMemory, sz.memoryMB*2); err == nil {
t.Error("more memory was allowed on a running machine with no hot-add")
}
// Off it goes, and with it the objection.
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
if err := refreshOne(s, &r); err != nil {
t.Fatalf("cannot re-read the machine: %v", err)
}
wantCPUs, wantMemory := sz.cpus+2, sz.memoryMB+1024
for _, c := range []struct {
kind sizeKind
want int32
}{{sizeCPUs, wantCPUs}, {sizeMemory, wantMemory}} {
// Read again between the two, so the second change is checked and
// described against what the first one left behind rather than against
// what the machine looked like before either.
sz, err = sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
t.Fatalf("runResize(%v): %v", c.kind.what(), err)
}
if !strings.Contains(msg, "→") {
t.Errorf("the message does not say what changed: %q", msg)
}
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the change: %v", err)
}
if after.cpus != wantCPUs {
t.Errorf("the machine has %d vCPUs, want %d", after.cpus, wantCPUs)
}
if after.memoryMB != wantMemory {
t.Errorf("the machine has %d MB, want %d", after.memoryMB, wantMemory)
}
// And the change that would take memory away from it while it runs is still
// refused after it has been given some.
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOn
if err := checkSize(r, after, sizeMemory, after.memoryMB-1024); err == nil {
t.Error("memory was taken away from a running machine")
}
}
// A command line that asks for two changes does neither until both are known to
// be possible: setting the vCPUs and then refusing the memory would leave half
// of what somebody asked for, which is the outcome nobody wanted.
func TestSimResizeRefusesBothOrNeither(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
before, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
// The vCPU count is good and the memory is not a multiple of 4 MB. -y so
// that a confirmation cannot be what stops it: the refusal has to come from
// the check, before anything is sent.
err = sizeCLI(vc, "DC0_C0_RP0_VM1", Itoa(int(before.cpus+2)), "1026m", true)
if err == nil {
t.Fatal("a half-impossible command line was accepted")
}
if !strings.Contains(err.Error(), "multiples of 4 MB") {
t.Errorf("refused for the wrong reason: %v", err)
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the refusal: %v", err)
}
if after.cpus != before.cpus {
t.Errorf("the vCPUs were changed anyway: %d, was %d", after.cpus, before.cpus)
}
if after.memoryMB != before.memoryMB {
t.Errorf("the memory was changed anyway: %d, was %d", after.memoryMB, before.memoryMB)
}
}
// refreshOne re-reads the properties the sweep reads, for a row a test has just
// changed something about.
func refreshOne(s *session, r *vmRow) error {
var fresh mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if err := vm.Properties(s.ctx, r.ref, sweepProps, &fresh); err != nil {
return err
}
r.vm = fresh
return nil
}
// The instance UUID is what the vSphere client's links are made of, and the one
// thing gvm cannot work out from the configuration.
func TestSimVsphereLinkFromASession(t *testing.T) {
+1 -1
View File
@@ -1 +1 @@
1.1.7
1.1.10