[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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fba15b7897
commit
db2f01908e
@@ -15,3 +15,4 @@ bin/
|
||||
tmp/
|
||||
gvm
|
||||
.gvmrc
|
||||
.claude/
|
||||
|
||||
@@ -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,48 @@ 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.
|
||||
|
||||
`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 +766,10 @@ 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,
|
||||
and — against the simulator — that a reconfigure actually lands, read back
|
||||
from the server rather than assumed
|
||||
* 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
|
||||
|
||||
+102
-3
@@ -147,7 +147,18 @@ 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, and only 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: the entries
|
||||
// are offered and the server has the last word.
|
||||
b.menuSize, err = sizingOf(r.sess, r.ref)
|
||||
if err != nil {
|
||||
b.setStatus(colWarn, 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)
|
||||
@@ -160,7 +171,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 +195,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 +210,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 +449,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 +702,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())
|
||||
|
||||
+17
-9
@@ -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,11 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
|
||||
t.Error("no task wait found at all — this check has stopped checking anything")
|
||||
}
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -131,6 +131,16 @@ 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".
|
||||
var sizeVM, sizeCPUs, sizeMemory string
|
||||
subSize := flaggy.NewSubcommand("size")
|
||||
subSize.Description = "Show or change a machine's vCPUs and memory"
|
||||
subSize.String(&sizeVM, "", "vm", "The machine to show or change")
|
||||
subSize.String(&sizeCPUs, "c", "cpus", "Set its vCPU count")
|
||||
subSize.String(&sizeMemory, "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 +168,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 +276,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 sizeVM == "" {
|
||||
return errf("size needs a machine: gvm size --vm <machine> [-c <vcpus>] [-m <memory>]")
|
||||
}
|
||||
return sizeCLI(vc, sizeVM, sizeCPUs, sizeMemory, yes)
|
||||
|
||||
case subHost.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// 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 is not a
|
||||
// failure to report on its own: the caller gets a sizing that says it knows
|
||||
// nothing, no objection is raised from here, and vCenter is left to refuse what
|
||||
// it will not take. Better a server's own error than a guess dressed as one.
|
||||
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) {
|
||||
// Nothing is claimed about a machine whose configuration could not be read.
|
||||
// vCenter is the authority on what it will take, and it says so itself.
|
||||
if !sz.known {
|
||||
return "", ""
|
||||
}
|
||||
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
|
||||
return "not a whole socket", SF("%s has %d cores per socket, so its vCPUs come in "+
|
||||
"multiples of %d — %d or %d, not %d", r.name, per, per, below, above, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ok, err := confirm(SF("%s on %s: %s → %s?", r.name, vc.Name,
|
||||
c.kind.shown(sz.now(c.kind)), c.kind.shown(want)), yes)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := runResize(s, r, sz, c.kind, want)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PO(msg)
|
||||
|
||||
// The second change is checked against what the first one left behind,
|
||||
// not against what was read before either of them.
|
||||
if c.kind == sizeCPUs {
|
||||
sz.cpus = want
|
||||
} else {
|
||||
sz.memoryMB = want
|
||||
}
|
||||
}
|
||||
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, ", ")
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
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"},
|
||||
|
||||
// A configuration that could not be read claims nothing: the server is
|
||||
// left to accept or refuse with its own reasons.
|
||||
{"unknown configuration", on, sizing{}, sizeCPUs, 64, ""},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+82
@@ -1218,6 +1218,88 @@ 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}} {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
@@ -1 +1 @@
|
||||
1.1.7
|
||||
1.1.8
|
||||
|
||||
Reference in New Issue
Block a user