d6105e58
fix(placement): the plane's refusal is never stricter than the host's admission
a73x 2026-08-12 14:51
Commit message
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -552,3 +552,21 @@ reads as what it is. The counters ride a nested message, so an agent that | |||
| 552 | never counted stays distinguishable from a port that counted zero. False if | 552 | never counted stays distinguishable from a port that counted zero. False if |
| 553 | anything alerts or bills on these numbers — that needs durable counters with | 553 | anything alerts or bills on these numbers — that needs durable counters with |
| 554 | an epoch, which these are deliberately not. | 554 | an epoch, which these are deliberately not. |
| 555 | |||
| 556 | ### An advertisement below the machine is a cap, and nothing else is | ||
| 557 | |||
| 558 | An agent advertises the machine's totals clamped to its `--max-*` flags and the | ||
| 559 | clamp only ever lowers, so a reported capacity below the size the same report's | ||
| 560 | metrics imply can only be a cap its operator set. Underpins the create-time | ||
| 561 | capacity refusal firing there and nowhere else: an uncapped agent admits | ||
| 562 | whatever it is asked — guest disks are sparse and guest memory is not | ||
| 563 | preallocated — so refusing against its raw totals would block the overcommit | ||
| 564 | such a host exists to do. **Proven** in code on both platforms and pinned by an | ||
| 565 | invariant test in `hostinfo`: memory's used+available is exactly the advertised | ||
| 566 | total, and disk's used+free undercounts it by the filesystem's reserved blocks, | ||
| 567 | an error that can only fail to prove a cap, never invent one. The wire carries | ||
| 568 | no cap flag, so three cases read as uncapped and fall back to the agent | ||
| 569 | refusing at boot — a `--max-vcpus` cap (no report carries a core count), a | ||
| 570 | failed probe, and a cap set at exactly the machine's total. False the moment | ||
| 571 | the wire says which a number is; the refusal should then judge the declared | ||
| 572 | capacity directly and this arithmetic goes away. | ||
internal/agent/hostinfo/hostinfo_test.go
| Old | New | ||
|---|---|---|---|
| @@ -220,6 +220,36 @@ func TestCapacityReportsTotals(t *testing.T) { | |||
| 220 | } | 220 | } |
| 221 | } | 221 | } |
| 222 | 222 | ||
| 223 | // TestMetricsImplyTheCapacityTheSameProbeSees guards a coupling that reaches | ||
| 224 | // all the way to the control plane's placement refusal. The agent advertises | ||
| 225 | // this machine's totals clamped to its --max-* flags, and the clamp only lowers | ||
| 226 | // — so an advertisement BELOW the machine's real size is the server's only | ||
| 227 | // proof that an operator set a cap, and the metrics in the same report are | ||
| 228 | // where it reads that real size (see api.declaredLimit). | ||
| 229 | // | ||
| 230 | // Memory must therefore land on the nose: readMetrics computes used as | ||
| 231 | // total-minus-available, so used+available is exactly Capacity's mem_mb. Disk | ||
| 232 | // may only UNDERCOUNT — used+free omits the filesystem's reserved blocks — and | ||
| 233 | // the direction is the safe one: an undercount can fail to prove a cap, never | ||
| 234 | // invent one. Break either and the server starts refusing creates on hosts | ||
| 235 | // whose agents would have run them. | ||
| 236 | func TestMetricsImplyTheCapacityTheSameProbeSees(t *testing.T) { | ||
| 237 | dir := t.TempDir() | ||
| 238 | c, m := Capacity(dir), Metrics(dir) | ||
| 239 | |||
| 240 | if m.GetMemAvailableMb() == 0 { | ||
| 241 | t.Skip("memory probe unavailable on this machine") | ||
| 242 | } | ||
| 243 | assert.Equal(t, c.GetMemMb(), m.GetMemUsedMb()+m.GetMemAvailableMb(), | ||
| 244 | "mem_used_mb + mem_available_mb must be exactly the advertised total") | ||
| 245 | |||
| 246 | if m.GetDiskFreeGb() == 0 && m.GetDiskUsedGb() == 0 { | ||
| 247 | t.Skip("disk probe unavailable on this machine") | ||
| 248 | } | ||
| 249 | assert.LessOrEqual(t, m.GetDiskUsedGb()+m.GetDiskFreeGb(), c.GetDiskGb(), | ||
| 250 | "disk_used_gb + disk_free_gb may undercount the total, never overstate it") | ||
| 251 | } | ||
| 252 | |||
| 223 | // TestCapacityExactArithmetic and TestCapacityBestEffortOnSyscallFailure live | 253 | // TestCapacityExactArithmetic and TestCapacityBestEffortOnSyscallFailure live |
| 224 | // in hostinfo_linux_test.go: they stub sysinfoFn/statfsFn with | 254 | // in hostinfo_linux_test.go: they stub sysinfoFn/statfsFn with |
| 225 | // syscall.Sysinfo_t/Statfs_t literals, which only exist on Linux. | 255 | // syscall.Sysinfo_t/Statfs_t literals, which only exist on Linux. |
internal/mcpserver/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -149,8 +149,9 @@ func TestVMCreateSurfacesThePreCSRRefusal(t *testing.T) { | |||
| 149 | // remedy. A model told only "409" would retry the same too-large VM; one told | 149 | // remedy. A model told only "409" would retry the same too-large VM; one told |
| 150 | // which dimension bound, and by how much, can ask for a size that fits. | 150 | // which dimension bound, and by how much, can ask for a size that fits. |
| 151 | func TestVMCreateSurfacesTheCapacityRefusal(t *testing.T) { | 151 | func TestVMCreateSurfacesTheCapacityRefusal(t *testing.T) { |
| 152 | const refusal = "host mewtwo (h1) has no room for this VM: vcpus — needs 8, already holds 4 of 6. " + | 152 | const refusal = "host mewtwo (h1) has no room for this VM: memory — needs 8192MB, already holds 4096 of 6144MB. " + |
| 153 | "A host runs no more than it reports. Ask for less, delete a VM on that host to free what it holds, " + | 153 | "That is a limit its operator set below what the machine has, so the agent there would refuse this VM " + |
| 154 | "at boot and it would sit failed. Ask for less, delete a VM on that host to free what it holds, " + | ||
| 154 | "or create it on a host with room." | 155 | "or create it on a host with room." |
| 155 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { | 156 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { |
| 156 | if r.URL.Path == "/api/v1/hosts" { | 157 | if r.URL.Path == "/api/v1/hosts" { |
| @@ -162,10 +163,10 @@ func TestVMCreateSurfacesTheCapacityRefusal(t *testing.T) { | |||
| 162 | http.Error(w, refusal, http.StatusConflict) | 163 | http.Error(w, refusal, http.StatusConflict) |
| 163 | }) | 164 | }) |
| 164 | tools := &Tools{API: c} | 165 | tools := &Tools{API: c} |
| 165 | _, err := tools.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", VCPUs: 8}) | 166 | _, err := tools.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", MemMB: 8192}) |
| 166 | require.Error(t, err) | 167 | require.Error(t, err) |
| 167 | assert.Contains(t, err.Error(), "has no room for this VM") | 168 | assert.Contains(t, err.Error(), "has no room for this VM") |
| 168 | assert.Contains(t, err.Error(), "vcpus — needs 8, already holds 4 of 6") | 169 | assert.Contains(t, err.Error(), "memory — needs 8192MB, already holds 4096 of 6144MB") |
| 169 | assert.Contains(t, err.Error(), "create it on a host with room") | 170 | assert.Contains(t, err.Error(), "create it on a host with room") |
| 170 | } | 171 | } |
| 171 | 172 | ||
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -853,11 +853,18 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 853 | } | 853 | } |
| 854 | 854 | ||
| 855 | // Capacity precondition, the third refusal of this same shape: the request | 855 | // Capacity precondition, the third refusal of this same shape: the request |
| 856 | // is fine, and the host cannot serve it. A host that is already holding as | 856 | // is fine, and the host has been told not to serve it. A host whose operator |
| 857 | // much as it says it has cannot boot one more guest — the agent refuses it | 857 | // capped it is already holding as much as it may hold cannot boot one more |
| 858 | // at materialization ("host capacity limit reached", see reconcile.Engine) | 858 | // guest — the agent refuses it at materialization ("host capacity limit |
| 859 | // and the VM ends up failed, minutes later, with an answer that existed | 859 | // reached", see reconcile.Engine) and the VM ends up failed, minutes later, |
| 860 | // here. So the create answers 409 now, naming the dimension and the numbers. | 860 | // with an answer that existed here. So the create answers 409 now, naming |
| 861 | // the dimension and the numbers. | ||
| 862 | // | ||
| 863 | // It answers 409 ONLY there. An uncapped host advertises the machine's | ||
| 864 | // totals and its agent admits past them on purpose (sparse disks, memory | ||
| 865 | // that is not preallocated), so treating those totals as a wall would refuse | ||
| 866 | // VMs the fleet is designed to run. overCapacityRefusal judges a dimension | ||
| 867 | // only when the report proves a cap, and says nothing otherwise. | ||
| 861 | // | 868 | // |
| 862 | // Judged only on a host that has spoken, exactly like the refusal above and | 869 | // Judged only on a host that has spoken, exactly like the refusal above and |
| 863 | // for the same reason: reported capacity is registry state, so an offline or | 870 | // for the same reason: reported capacity is registry state, so an offline or |
| @@ -872,7 +879,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 872 | return | 879 | return |
| 873 | } | 880 | } |
| 874 | want := store.Alloc{VCPUs: req.VCPUs, MemMB: req.MemMB, DiskGB: req.DiskGB} | 881 | want := store.Alloc{VCPUs: req.VCPUs, MemMB: req.MemMB, DiskGB: req.DiskGB} |
| 875 | if msg := overCapacityRefusal(host.Name, req.HostID, want, held, hostState.Capacity); msg != "" { | 882 | if msg := overCapacityRefusal(host.Name, req.HostID, want, held, hostState.Report); msg != "" { |
| 876 | http.Error(w, msg, http.StatusConflict) | 883 | http.Error(w, msg, http.StatusConflict) |
| 877 | return | 884 | return |
| 878 | } | 885 | } |
internal/server/api/capacity.go
| Old | New | ||
|---|---|---|---|
| @@ -8,39 +8,143 @@ import ( | |||
| 8 | "github.com/a73x/eitri/internal/server/store" | 8 | "github.com/a73x/eitri/internal/server/store" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // declaredLimit answers the only question this refusal may rest on: is the | ||
| 12 | // number this host advertised for a dimension a limit its operator declared, or | ||
| 13 | // is it just the size of the machine? It returns the limit when the report | ||
| 14 | // proves the first, and 0 — "nothing proven, do not judge" — otherwise. | ||
| 15 | // | ||
| 16 | // The proof is arithmetic on the host's own report. An agent advertises the | ||
| 17 | // machine's totals clamped to its --max-vcpus/--max-mem-mb/--max-disk-gb flags | ||
| 18 | // (syncclient.advertisedCapacity), and the clamp only ever lowers, so an | ||
| 19 | // advertisement BELOW the machine's total can only be a configured cap. The | ||
| 20 | // machine's total is not on the wire as such, but the metrics in the same | ||
| 21 | // report imply it: the agent computes used as total-minus-available, so | ||
| 22 | // mem_used_mb + mem_available_mb is exactly the machine's memory, and | ||
| 23 | // disk_used_gb + disk_free_gb is the state dir's filesystem minus its reserved | ||
| 24 | // blocks — an UNDERCOUNT, which is the safe direction here (it can only fail to | ||
| 25 | // prove a cap, never invent one). | ||
| 26 | // | ||
| 27 | // This is a coupling to hostinfo.readMetrics on both platforms, and an | ||
| 28 | // invariant test there guards it (TestMetricsImplyTheCapacityTheSameProbeSees). | ||
| 29 | // If that identity is ever broken the failure is a refusal that stops firing, | ||
| 30 | // not one that fires wrongly. | ||
| 31 | // | ||
| 32 | // Everything unprovable reads as uncapped, and the list is worth naming: vCPUs | ||
| 33 | // always (no report carries the machine's core count, so a --max-vcpus cap is | ||
| 34 | // invisible here), a dimension whose probe failed and reports zeroes, and a cap | ||
| 35 | // set at exactly the machine's total. Each of those falls back to the world | ||
| 36 | // before this preflight existed — the agent refuses at boot and the VM sits | ||
| 37 | // failed — which is the honest cost of not refusing a VM the host would have | ||
| 38 | // run. See overCapacityRefusal for why that asymmetry is the whole design. | ||
| 39 | func declaredLimit(advertised, machineTotal int64) int64 { | ||
| 40 | if advertised <= 0 || machineTotal <= 0 || advertised >= machineTotal { | ||
| 41 | return 0 | ||
| 42 | } | ||
| 43 | return advertised | ||
| 44 | } | ||
| 45 | |||
| 46 | // dimension is one resource under judgement: what the VM asked for, what the | ||
| 47 | // host is holding (split live vs. still-being-torn-down), and the limit — 0 | ||
| 48 | // when this dimension is not judged at all. | ||
| 49 | type dimension struct { | ||
| 50 | noun, unit string | ||
| 51 | want, live, pending int64 | ||
| 52 | limit int64 | ||
| 53 | } | ||
| 54 | |||
| 55 | func (d dimension) overWithPending() bool { return d.limit > 0 && d.live+d.pending+d.want > d.limit } | ||
| 56 | func (d dimension) overLiveAlone() bool { return d.limit > 0 && d.live+d.want > d.limit } | ||
| 57 | |||
| 58 | // held phrases the binding numbers the way an operator has to act on them: | ||
| 59 | // what was asked for, what is already there, out of what. | ||
| 60 | func (d dimension) held() string { | ||
| 61 | return fmt.Sprintf("%s — needs %d%s, already holds %d of %d%s", | ||
| 62 | d.noun, d.want, d.unit, d.live+d.pending, d.limit, d.unit) | ||
| 63 | } | ||
| 64 | |||
| 65 | // heldWithPending is the same line for a refusal a teardown is tipping: the | ||
| 66 | // share held by VMs on their way out is the part that is about to go away. | ||
| 67 | func (d dimension) heldWithPending() string { | ||
| 68 | return fmt.Sprintf("%s, %d%s of it by the teardown", d.held(), d.pending, d.unit) | ||
| 69 | } | ||
| 70 | |||
| 11 | // overCapacityRefusal explains why a host cannot be given a VM: it is already | 71 | // overCapacityRefusal explains why a host cannot be given a VM: it is already |
| 12 | // holding as much as it says it has. It returns "" when the VM fits. | 72 | // holding as much as its operator said it may. It returns "" when the VM fits, |
| 73 | // and — deliberately — whenever this server cannot prove the host would refuse. | ||
| 13 | // | 74 | // |
| 14 | // The answer exists at create — the host's capacity and everything already on | 75 | // The answer exists at create — the host's capacity and everything already on |
| 15 | // it are both known here — so the caller learns it in milliseconds instead of | 76 | // it are both known here — so the caller learns it in milliseconds instead of |
| 16 | // watching a VM sit `creating` and then `failed` because the agent reached the | 77 | // watching a VM sit `creating` and then `failed` because the agent reached the |
| 17 | // same conclusion minutes later, at the far end of an image download. The | 78 | // same conclusion minutes later, at the far end of an image download. |
| 18 | // agent's own admission check stays where it is: it is the authority for the | ||
| 19 | // race this one cannot close (two creates in flight, capacity re-reported | ||
| 20 | // smaller between check and boot) and the only judge a serverless host has. | ||
| 21 | // | 79 | // |
| 22 | // Every dimension the host has spoken about is judged, and each binding one is | 80 | // The agent's admission is the AUTHORITY; this is a courtesy that runs first, |
| 23 | // named: a VM that is too big in two ways should be resized once, not twice. A | 81 | // and a courtesy must never be stricter than the authority it anticipates. The |
| 24 | // dimension whose reported capacity is 0 is skipped — an agent reports totals, | 82 | // agent refuses a boot only against a configured cap (reconcile.quotaCheckLocked |
| 25 | // and a zero is a failed probe (or a report that predates the field), not a | 83 | // — with no --max-* flag it admits whatever it is asked, and that is the design: |
| 26 | // host with no memory. Judging on it would refuse every VM on the fleet. | 84 | // guest disks are sparse and memory is not preallocated, so a host deliberately |
| 27 | func overCapacityRefusal(hostName, hostID string, want, held store.Alloc, cap registry.Capacity) string { | 85 | // runs more than it has on paper). So this refuses only where declaredLimit can |
| 28 | var over []string | 86 | // prove a cap exists, and stays silent everywhere else. Refusing a VM the host |
| 29 | dim := func(noun, unit string, want, held, capacity int64) { | 87 | // would have run is the one failure with no remedy short of an ssh session; |
| 30 | if capacity <= 0 || held+want <= capacity { | 88 | // missing one it will refuse costs the caller the wait it always used to cost. |
| 31 | return | 89 | // |
| 90 | // Every judged dimension that binds is named: a VM that is too big in two ways | ||
| 91 | // should be resized once, not twice. | ||
| 92 | func overCapacityRefusal(hostName, hostID string, want store.Alloc, held store.Commitment, rep registry.Report) string { | ||
| 93 | m := rep.Metrics | ||
| 94 | dims := []dimension{ | ||
| 95 | {noun: "vcpus", want: want.VCPUs, live: held.Live.VCPUs, pending: held.Pending.VCPUs, | ||
| 96 | limit: declaredLimit(rep.Capacity.VCPUs, 0)}, | ||
| 97 | {noun: "memory", unit: "MB", want: want.MemMB, live: held.Live.MemMB, pending: held.Pending.MemMB, | ||
| 98 | limit: declaredLimit(rep.Capacity.MemMB, m.MemUsedMB+m.MemAvailableMB)}, | ||
| 99 | {noun: "disk", unit: "GB", want: want.DiskGB, live: held.Live.DiskGB, pending: held.Pending.DiskGB, | ||
| 100 | limit: declaredLimit(rep.Capacity.DiskGB, m.DiskUsedGB+m.DiskFreeGB)}, | ||
| 101 | } | ||
| 102 | |||
| 103 | var binding []dimension | ||
| 104 | liveAloneFits := true | ||
| 105 | for _, d := range dims { | ||
| 106 | if d.overWithPending() { | ||
| 107 | binding = append(binding, d) | ||
| 108 | } | ||
| 109 | if d.overLiveAlone() { | ||
| 110 | liveAloneFits = false | ||
| 32 | } | 111 | } |
| 33 | over = append(over, fmt.Sprintf("%s — needs %d%s, already holds %d of %d%s", | ||
| 34 | noun, want, unit, held, capacity, unit)) | ||
| 35 | } | 112 | } |
| 36 | dim("vcpus", "", want.VCPUs, held.VCPUs, cap.VCPUs) | 113 | if len(binding) == 0 { |
| 37 | dim("memory", "MB", want.MemMB, held.MemMB, cap.MemMB) | ||
| 38 | dim("disk", "GB", want.DiskGB, held.DiskGB, cap.DiskGB) | ||
| 39 | if len(over) == 0 { | ||
| 40 | return "" | 114 | return "" |
| 41 | } | 115 | } |
| 42 | return fmt.Sprintf("host %s (%s) has no room for this VM: %s. A host runs no more than it reports, so the "+ | 116 | |
| 43 | "agent there would refuse this VM at boot and it would sit failed. Ask for less, delete a VM on that "+ | 117 | // A destroy in flight is the reason, and "delete a VM" would be a lie: the |
| 44 | "host to free what it holds, or create it on a host with room.", | 118 | // caller may well have just deleted one, and is being refused by the very |
| 45 | hostName, hostID, strings.Join(over, "; ")) | 119 | // resources that delete has not finished releasing. Say what is actually |
| 120 | // happening and give the remedy that matches it — the wait ends by itself. | ||
| 121 | if liveAloneFits { | ||
| 122 | var lines []string | ||
| 123 | for _, d := range binding { | ||
| 124 | lines = append(lines, d.heldWithPending()) | ||
| 125 | } | ||
| 126 | return fmt.Sprintf("host %s (%s) has no room for this VM yet: %s. %s, and a deleted VM holds its "+ | ||
| 127 | "vcpus, memory and disk until its host finishes tearing it down — the VMs still running there "+ | ||
| 128 | "would leave room for this one. Wait for the teardown to finish and create it again, ask for "+ | ||
| 129 | "less, or create it on a host with room.", | ||
| 130 | hostName, hostID, strings.Join(lines, "; "), pendingTeardowns(held.PendingVMs)) | ||
| 131 | } | ||
| 132 | |||
| 133 | var lines []string | ||
| 134 | for _, d := range binding { | ||
| 135 | lines = append(lines, d.held()) | ||
| 136 | } | ||
| 137 | return fmt.Sprintf("host %s (%s) has no room for this VM: %s. That is a limit its operator set below "+ | ||
| 138 | "what the machine has, so the agent there would refuse this VM at boot and it would sit failed. "+ | ||
| 139 | "Ask for less, delete a VM on that host to free what it holds, or create it on a host with room.", | ||
| 140 | hostName, hostID, strings.Join(lines, "; ")) | ||
| 141 | } | ||
| 142 | |||
| 143 | // pendingTeardowns names the deletes still in flight, counted, so the caller | ||
| 144 | // can tell "the one I just deleted" from "somebody else is clearing the host". | ||
| 145 | func pendingTeardowns(n int) string { | ||
| 146 | if n == 1 { | ||
| 147 | return "1 VM deleted from that host is still being torn down" | ||
| 148 | } | ||
| 149 | return fmt.Sprintf("%d VMs deleted from that host are still being torn down", n) | ||
| 46 | } | 150 | } |
internal/server/api/capacity_api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -13,12 +13,33 @@ import ( | |||
| 13 | "github.com/a73x/eitri/internal/server/registry" | 13 | "github.com/a73x/eitri/internal/server/registry" |
| 14 | ) | 14 | ) |
| 15 | 15 | ||
| 16 | // agentReportsCapacity is the report of a host that has told this server what | 16 | // agentReportsCappedCapacity is the report of a host whose operator held some |
| 17 | // machine it is: the create path judges placement only on a host that has | 17 | // of it back: it advertises less than the machine it also measures. That gap is |
| 18 | // spoken, and this is what speaking sounds like. | 18 | // the only proof this server gets that a cap exists — and a cap is the only |
| 19 | func agentReportsCapacity(hostID string, vcpus, memMB, diskGB int64) { | 19 | // thing the agent will refuse a boot against — so it is the setup for every |
| 20 | // refusal below. The metrics are a machine an order of magnitude bigger than | ||
| 21 | // what it offers, which is what --max-mem-mb/--max-disk-gb produce. | ||
| 22 | func agentReportsCappedCapacity(hostID string, vcpus, memMB, diskGB int64) { | ||
| 20 | testReg.UpdateReport(hostID, registry.Report{ | 23 | testReg.UpdateReport(hostID, registry.Report{ |
| 21 | Capacity: registry.Capacity{VCPUs: vcpus, MemMB: memMB, DiskGB: diskGB}, | 24 | Capacity: registry.Capacity{VCPUs: vcpus, MemMB: memMB, DiskGB: diskGB}, |
| 25 | Metrics: registry.Metrics{ | ||
| 26 | MemUsedMB: memMB, MemAvailableMB: memMB * 10, | ||
| 27 | DiskUsedGB: diskGB, DiskFreeGB: diskGB * 10, | ||
| 28 | }, | ||
| 29 | }) | ||
| 30 | } | ||
| 31 | |||
| 32 | // agentReportsUncappedCapacity is the other host: no --max-* flags, so it | ||
| 33 | // advertises exactly the machine its own metrics describe. Nothing here says | ||
| 34 | // "limit" — an uncapped agent admits whatever it is asked — so nothing here may | ||
| 35 | // refuse a create. | ||
| 36 | func agentReportsUncappedCapacity(hostID string, vcpus, memMB, diskGB int64) { | ||
| 37 | testReg.UpdateReport(hostID, registry.Report{ | ||
| 38 | Capacity: registry.Capacity{VCPUs: vcpus, MemMB: memMB, DiskGB: diskGB}, | ||
| 39 | Metrics: registry.Metrics{ | ||
| 40 | MemUsedMB: memMB / 4, MemAvailableMB: memMB - memMB/4, | ||
| 41 | DiskUsedGB: diskGB / 4, DiskFreeGB: diskGB - diskGB/4, | ||
| 42 | }, | ||
| 22 | }) | 43 | }) |
| 23 | } | 44 | } |
| 24 | 45 | ||
| @@ -40,23 +61,23 @@ func bodyOf(t *testing.T, resp *http.Response) string { | |||
| 40 | } | 61 | } |
| 41 | 62 | ||
| 42 | // TestCreateVMRefusesAHostWithNoRoom pins the third refusal of this shape: the | 63 | // TestCreateVMRefusesAHostWithNoRoom pins the third refusal of this shape: the |
| 43 | // request is fine and the host cannot serve it. Each dimension binds on its | 64 | // request is fine and the host has been told not to serve it. Each dimension |
| 44 | // own, and the refusal has to be actionable on its own terms — which dimension, | 65 | // binds on its own, and the refusal has to be actionable on its own terms — |
| 45 | // what the VM asked for, what the host is already holding out of what it has. | 66 | // which dimension, what the VM asked for, what the host is already holding out |
| 67 | // of what it may hold. | ||
| 46 | func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { | 68 | func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { |
| 47 | for _, tc := range []struct { | 69 | for _, tc := range []struct { |
| 48 | name string | 70 | name string |
| 49 | vcpus, memMB, diskGB int64 | 71 | vcpus, memMB, diskGB int64 |
| 50 | want string | 72 | want string |
| 51 | }{ | 73 | }{ |
| 52 | {"vcpus", 8, 1024, 10, "vcpus — needs 8, already holds 2 of 6"}, | ||
| 53 | {"memory", 1, 8192, 10, "memory — needs 8192MB, already holds 2048 of 4096MB"}, | 74 | {"memory", 1, 8192, 10, "memory — needs 8192MB, already holds 2048 of 4096MB"}, |
| 54 | {"disk", 1, 1024, 200, "disk — needs 200GB, already holds 10 of 100GB"}, | 75 | {"disk", 1, 1024, 200, "disk — needs 200GB, already holds 10 of 100GB"}, |
| 55 | } { | 76 | } { |
| 56 | t.Run(tc.name, func(t *testing.T) { | 77 | t.Run(tc.name, func(t *testing.T) { |
| 57 | ts, st, _ := testServer(t) | 78 | ts, st, _ := testServer(t) |
| 58 | out := enroll(t, ts) | 79 | out := enroll(t, ts) |
| 59 | agentReportsCapacity(out["host_id"], 6, 4096, 100) | 80 | agentReportsCappedCapacity(out["host_id"], 6, 4096, 100) |
| 60 | 81 | ||
| 61 | // One VM already there, so the refusal has a "holds" to report. | 82 | // One VM already there, so the refusal has a "holds" to report. |
| 62 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "sitting", 2, 2048, 10).StatusCode) | 83 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "sitting", 2, 2048, 10).StatusCode) |
| @@ -66,6 +87,7 @@ func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { | |||
| 66 | msg := bodyOf(t, resp) | 87 | msg := bodyOf(t, resp) |
| 67 | assert.Contains(t, msg, tc.want) | 88 | assert.Contains(t, msg, tc.want) |
| 68 | assert.Contains(t, msg, "host host-a ("+out["host_id"]+")", "the refusal must name the host") | 89 | assert.Contains(t, msg, "host host-a ("+out["host_id"]+")", "the refusal must name the host") |
| 90 | assert.Contains(t, msg, "delete a VM on that host", "live VMs fill it: the remedy is to free one") | ||
| 69 | assert.Contains(t, msg, "create it on a host with room", "the refusal must name a way out") | 91 | assert.Contains(t, msg, "create it on a host with room", "the refusal must name a way out") |
| 70 | 92 | ||
| 71 | vms, err := st.ListVMs() | 93 | vms, err := st.ListVMs() |
| @@ -80,31 +102,68 @@ func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { | |||
| 80 | func TestCreateVMRefusalNamesEveryBindingDimension(t *testing.T) { | 102 | func TestCreateVMRefusalNamesEveryBindingDimension(t *testing.T) { |
| 81 | ts, _, _ := testServer(t) | 103 | ts, _, _ := testServer(t) |
| 82 | out := enroll(t, ts) | 104 | out := enroll(t, ts) |
| 83 | agentReportsCapacity(out["host_id"], 6, 4096, 100) | 105 | agentReportsCappedCapacity(out["host_id"], 6, 4096, 100) |
| 84 | 106 | ||
| 85 | resp := createVM(t, ts, out["host_id"], "huge", 8, 8192, 10) | 107 | resp := createVM(t, ts, out["host_id"], "huge", 1, 8192, 200) |
| 86 | require.Equal(t, 409, resp.StatusCode) | 108 | require.Equal(t, 409, resp.StatusCode) |
| 87 | msg := bodyOf(t, resp) | 109 | msg := bodyOf(t, resp) |
| 88 | assert.Contains(t, msg, "vcpus — needs 8, already holds 0 of 6") | ||
| 89 | assert.Contains(t, msg, "memory — needs 8192MB, already holds 0 of 4096MB") | 110 | assert.Contains(t, msg, "memory — needs 8192MB, already holds 0 of 4096MB") |
| 90 | assert.NotContains(t, msg, "disk —", "a dimension that fits is not the caller's problem") | 111 | assert.Contains(t, msg, "disk — needs 200GB, already holds 0 of 100GB") |
| 91 | } | 112 | } |
| 92 | 113 | ||
| 93 | // TestCreateVMFillsAHostExactly is the boundary: capacity is a limit, not a | 114 | // TestCreateVMFillsAHostExactly is the boundary: a declared capacity is a |
| 94 | // threshold. A VM that fits the last of the host is placed, and the next one — | 115 | // limit, not a threshold. A VM that fits the last of the host is placed, and |
| 95 | // however small — is refused, because there is nothing left. | 116 | // the next one — however small — is refused, because there is nothing left. |
| 96 | func TestCreateVMFillsAHostExactly(t *testing.T) { | 117 | func TestCreateVMFillsAHostExactly(t *testing.T) { |
| 97 | ts, _, _ := testServer(t) | 118 | ts, _, _ := testServer(t) |
| 98 | out := enroll(t, ts) | 119 | out := enroll(t, ts) |
| 99 | agentReportsCapacity(out["host_id"], 4, 4096, 40) | 120 | agentReportsCappedCapacity(out["host_id"], 4, 4096, 40) |
| 100 | 121 | ||
| 101 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "half", 2, 2048, 20).StatusCode) | 122 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "half", 1, 2048, 20).StatusCode) |
| 102 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "rest", 2, 2048, 20).StatusCode, | 123 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "rest", 1, 2048, 20).StatusCode, |
| 103 | "a VM that exactly fills the host still fits") | 124 | "a VM that exactly fills the host still fits") |
| 104 | 125 | ||
| 105 | resp := createVM(t, ts, out["host_id"], "onemore", 1, 1, 1) | 126 | resp := createVM(t, ts, out["host_id"], "onemore", 1, 1, 1) |
| 106 | require.Equal(t, 409, resp.StatusCode) | 127 | require.Equal(t, 409, resp.StatusCode) |
| 107 | assert.Contains(t, bodyOf(t, resp), "vcpus — needs 1, already holds 4 of 4") | 128 | assert.Contains(t, bodyOf(t, resp), "memory — needs 1MB, already holds 4096 of 4096MB") |
| 129 | } | ||
| 130 | |||
| 131 | // TestCreateVMNeverRefusesAnUncappedHost is the rule this whole preflight is | ||
| 132 | // bounded by: the agent's admission is the authority, and an agent with no | ||
| 133 | // --max-* flag admits whatever it is asked. A host that advertises the machine | ||
| 134 | // it measures has declared no limit, so its totals are not a wall — guest disks | ||
| 135 | // are sparse and guest memory is not preallocated, and running well past the | ||
| 136 | // paper numbers is what such a host is for. A courtesy check that refused here | ||
| 137 | // would block VMs the fleet is designed to run, and no operator could see why | ||
| 138 | // without an ssh session. | ||
| 139 | func TestCreateVMNeverRefusesAnUncappedHost(t *testing.T) { | ||
| 140 | ts, st, _ := testServer(t) | ||
| 141 | out := enroll(t, ts) | ||
| 142 | agentReportsUncappedCapacity(out["host_id"], 4, 4096, 100) | ||
| 143 | |||
| 144 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "big", 8, 8192, 400).StatusCode, | ||
| 145 | "one VM may exceed the whole machine") | ||
| 146 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "bigger", 8, 8192, 400).StatusCode, | ||
| 147 | "and so may the next one") | ||
| 148 | |||
| 149 | vms, err := st.ListVMs() | ||
| 150 | require.NoError(t, err) | ||
| 151 | assert.Len(t, vms, 2) | ||
| 152 | } | ||
| 153 | |||
| 154 | // TestCreateVMNeverJudgesVCPUs is the honest edge of the proof. A cap is proven | ||
| 155 | // by an advertisement below the machine's own measurements, and no report | ||
| 156 | // carries the machine's core count — so a --max-vcpus cap is invisible here and | ||
| 157 | // a vCPU overflow is never refused, even on a host proven capped in memory. The | ||
| 158 | // agent still enforces it at boot; this server just cannot say so first, and | ||
| 159 | // guessing would refuse every oversubscribed VM on the fleet. | ||
| 160 | func TestCreateVMNeverJudgesVCPUs(t *testing.T) { | ||
| 161 | ts, _, _ := testServer(t) | ||
| 162 | out := enroll(t, ts) | ||
| 163 | agentReportsCappedCapacity(out["host_id"], 2, 4096, 100) | ||
| 164 | |||
| 165 | resp := createVM(t, ts, out["host_id"], "manycpus", 64, 1024, 10) | ||
| 166 | assert.Equal(t, 201, resp.StatusCode) | ||
| 108 | } | 167 | } |
| 109 | 168 | ||
| 110 | // TestCreateVMJudgesOnlyAHostThatHasReportedCapacity: reported capacity is | 169 | // TestCreateVMJudgesOnlyAHostThatHasReportedCapacity: reported capacity is |
| @@ -140,6 +199,17 @@ func TestCreateVMJudgesOnlyAHostThatHasReportedCapacity(t *testing.T) { | |||
| 140 | vms, _ := dbst.ListVMs() | 199 | vms, _ := dbst.ListVMs() |
| 141 | assert.Len(t, vms, 1) | 200 | assert.Len(t, vms, 1) |
| 142 | }) | 201 | }) |
| 202 | |||
| 203 | t.Run("capacity but no metrics to measure it against", func(t *testing.T) { | ||
| 204 | ts, dbst, _ := testServer(t) | ||
| 205 | out := enroll(t, ts) | ||
| 206 | testReg.UpdateReport(out["host_id"], registry.Report{ | ||
| 207 | Capacity: registry.Capacity{VCPUs: 4, MemMB: 4096, DiskGB: 40}}) | ||
| 208 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "unjudged", 8, 99999, 999).StatusCode, | ||
| 209 | "without the machine's size, nothing proves that capacity is a limit") | ||
| 210 | vms, _ := dbst.ListVMs() | ||
| 211 | assert.Len(t, vms, 1) | ||
| 212 | }) | ||
| 143 | } | 213 | } |
| 144 | 214 | ||
| 145 | // TestCreateVMCountsATombstonedVMUntilItIsReaped is the predicate the whole | 215 | // TestCreateVMCountsATombstonedVMUntilItIsReaped is the predicate the whole |
| @@ -147,24 +217,59 @@ func TestCreateVMJudgesOnlyAHostThatHasReportedCapacity(t *testing.T) { | |||
| 147 | // the host and the guest may still be shutting down, so its resources are not | 217 | // the host and the guest may still be shutting down, so its resources are not |
| 148 | // free to promise to somebody else. Only the reap — the agent's ack, or the | 218 | // free to promise to somebody else. Only the reap — the agent's ack, or the |
| 149 | // abandoned sweep — frees them, and it frees them by removing the row. | 219 | // abandoned sweep — frees them, and it frees them by removing the row. |
| 220 | // | ||
| 221 | // It is also the case where the obvious remedy is a lie. The caller is being | ||
| 222 | // refused BY the VM they just deleted, so "delete a VM" would send them looking | ||
| 223 | // for one that is already gone; the refusal has to name the teardown and say to | ||
| 224 | // wait for it, because that wait ends by itself. | ||
| 150 | func TestCreateVMCountsATombstonedVMUntilItIsReaped(t *testing.T) { | 225 | func TestCreateVMCountsATombstonedVMUntilItIsReaped(t *testing.T) { |
| 151 | ts, dbst, _ := testServer(t) | 226 | ts, dbst, _ := testServer(t) |
| 152 | out := enroll(t, ts) | 227 | out := enroll(t, ts) |
| 153 | agentReportsCapacity(out["host_id"], 4, 4096, 40) | 228 | agentReportsCappedCapacity(out["host_id"], 4, 4096, 40) |
| 154 | 229 | ||
| 155 | resp := createVM(t, ts, out["host_id"], "first", 4, 4096, 40) | 230 | resp := createVM(t, ts, out["host_id"], "first", 1, 4096, 40) |
| 156 | require.Equal(t, 201, resp.StatusCode) | 231 | require.Equal(t, 201, resp.StatusCode) |
| 157 | var created map[string]string | 232 | var created map[string]string |
| 158 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) | 233 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) |
| 159 | 234 | ||
| 160 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+created["id"], testPAT, nil).StatusCode) | 235 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+created["id"], testPAT, nil).StatusCode) |
| 161 | 236 | ||
| 162 | refused := createVM(t, ts, out["host_id"], "second", 4, 4096, 40) | 237 | refused := createVM(t, ts, out["host_id"], "second", 1, 4096, 40) |
| 163 | require.Equal(t, 409, refused.StatusCode, "a deleting VM still occupies the host") | 238 | require.Equal(t, 409, refused.StatusCode, "a deleting VM still occupies the host") |
| 164 | assert.Contains(t, bodyOf(t, refused), "already holds 4 of 4") | 239 | msg := bodyOf(t, refused) |
| 240 | assert.Contains(t, msg, "memory — needs 4096MB, already holds 4096 of 4096MB, 4096MB of it by the teardown") | ||
| 241 | assert.Contains(t, msg, "1 VM deleted from that host is still being torn down", | ||
| 242 | "the refusal must name how many teardowns are holding the room") | ||
| 243 | assert.Contains(t, msg, "Wait for the teardown to finish", "the wait is the remedy") | ||
| 244 | assert.NotContains(t, msg, "delete a VM on that host", | ||
| 245 | "the caller already deleted one; sending them to delete another is the lie") | ||
| 165 | 246 | ||
| 166 | // The agent acks the destroy: the row goes, and with it the commitment. | 247 | // The agent acks the destroy: the row goes, and with it the commitment. |
| 167 | require.NoError(t, dbst.HardDeleteVM(created["id"])) | 248 | require.NoError(t, dbst.HardDeleteVM(created["id"])) |
| 168 | assert.Equal(t, 201, createVM(t, ts, out["host_id"], "second", 4, 4096, 40).StatusCode, | 249 | assert.Equal(t, 201, createVM(t, ts, out["host_id"], "second", 1, 4096, 40).StatusCode, |
| 169 | "a reaped VM holds nothing") | 250 | "a reaped VM holds nothing") |
| 170 | } | 251 | } |
| 252 | |||
| 253 | // TestCreateVMKeepsTheDeleteRemedyWhenLiveVMsFillTheHost is the other half of | ||
| 254 | // the pair: a teardown in flight does not soften a host that is full without | ||
| 255 | // it. The VMs holding the room are really there, so the remedy is really to | ||
| 256 | // free one. | ||
| 257 | func TestCreateVMKeepsTheDeleteRemedyWhenLiveVMsFillTheHost(t *testing.T) { | ||
| 258 | ts, _, _ := testServer(t) | ||
| 259 | out := enroll(t, ts) | ||
| 260 | agentReportsCappedCapacity(out["host_id"], 8, 8192, 80) | ||
| 261 | |||
| 262 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "living", 1, 6144, 60).StatusCode) | ||
| 263 | dying := createVM(t, ts, out["host_id"], "dying", 1, 2048, 20) | ||
| 264 | require.Equal(t, 201, dying.StatusCode) | ||
| 265 | var created map[string]string | ||
| 266 | require.NoError(t, json.NewDecoder(dying.Body).Decode(&created)) | ||
| 267 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+created["id"], testPAT, nil).StatusCode) | ||
| 268 | |||
| 269 | refused := createVM(t, ts, out["host_id"], "next", 1, 4096, 40) | ||
| 270 | require.Equal(t, 409, refused.StatusCode) | ||
| 271 | msg := bodyOf(t, refused) | ||
| 272 | assert.Contains(t, msg, "delete a VM on that host", | ||
| 273 | "the live VMs alone leave no room: waiting for the teardown will not help") | ||
| 274 | assert.NotContains(t, msg, "still being torn down") | ||
| 275 | } | ||
internal/server/store/allocation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -72,16 +72,35 @@ func TestCommittedOnHostCountsTombstonesUntilTheyAreReaped(t *testing.T) { | |||
| 72 | 72 | ||
| 73 | held, err := s.CommittedOnHost(h.ID) | 73 | held, err := s.CommittedOnHost(h.ID) |
| 74 | require.NoError(t, err) | 74 | require.NoError(t, err) |
| 75 | assert.Equal(t, Alloc{VCPUs: 6, MemMB: 6144, DiskGB: 30}, held, | 75 | assert.Equal(t, Alloc{VCPUs: 6, MemMB: 6144, DiskGB: 30}, held.Held(), |
| 76 | "a VM being destroyed still occupies the host") | 76 | "a VM being destroyed still occupies the host") |
| 77 | 77 | ||
| 78 | require.NoError(t, s.HardDeleteVM(dying.ID)) | 78 | require.NoError(t, s.HardDeleteVM(dying.ID)) |
| 79 | held, err = s.CommittedOnHost(h.ID) | 79 | held, err = s.CommittedOnHost(h.ID) |
| 80 | require.NoError(t, err) | 80 | require.NoError(t, err) |
| 81 | assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held, | 81 | assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held.Held(), |
| 82 | "the reap is what frees it") | 82 | "the reap is what frees it") |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | // TestCommittedOnHostSplitsWhatIsRunningFromWhatIsLeaving is the half of the | ||
| 86 | // commitment a refusal has to be able to name. Both halves are held, and only | ||
| 87 | // one of them can be freed by deleting something — the other frees itself. | ||
| 88 | func TestCommittedOnHostSplitsWhatIsRunningFromWhatIsLeaving(t *testing.T) { | ||
| 89 | s := newStore(t) | ||
| 90 | h := enrollHost(t, s) | ||
| 91 | vmWithResources(t, s, h, "a", 2, 2048, 10) | ||
| 92 | for _, name := range []string{"b", "c"} { | ||
| 93 | dying := vmWithResources(t, s, h, name, 4, 4096, 20) | ||
| 94 | require.NoError(t, s.TombstoneVM(dying.ID)) | ||
| 95 | } | ||
| 96 | |||
| 97 | held, err := s.CommittedOnHost(h.ID) | ||
| 98 | require.NoError(t, err) | ||
| 99 | assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held.Live) | ||
| 100 | assert.Equal(t, Alloc{VCPUs: 8, MemMB: 8192, DiskGB: 40}, held.Pending) | ||
| 101 | assert.Equal(t, 2, held.PendingVMs, "the count is what a refusal names") | ||
| 102 | } | ||
| 103 | |||
| 85 | // TestCommittedOnHostSeparatesHosts: one host's commitment says nothing about | 104 | // TestCommittedOnHostSeparatesHosts: one host's commitment says nothing about |
| 86 | // another's, and a host with nothing on it holds nothing rather than erroring. | 105 | // another's, and a host with nothing on it holds nothing rather than erroring. |
| 87 | func TestCommittedOnHostSeparatesHosts(t *testing.T) { | 106 | func TestCommittedOnHostSeparatesHosts(t *testing.T) { |
| @@ -91,7 +110,7 @@ func TestCommittedOnHostSeparatesHosts(t *testing.T) { | |||
| 91 | 110 | ||
| 92 | held, err := s.CommittedOnHost("no-such-host") | 111 | held, err := s.CommittedOnHost("no-such-host") |
| 93 | require.NoError(t, err) | 112 | require.NoError(t, err) |
| 94 | assert.Equal(t, Alloc{}, held) | 113 | assert.Equal(t, Commitment{}, held) |
| 95 | } | 114 | } |
| 96 | 115 | ||
| 97 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { | 116 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -978,6 +978,27 @@ func allocatedByHost(q querier) (map[string]Alloc, error) { | |||
| 978 | return out, rows.Err() | 978 | return out, rows.Err() |
| 979 | } | 979 | } |
| 980 | 980 | ||
| 981 | // Commitment is everything a host is holding, split by why it is holding it. | ||
| 982 | // Live is the VMs it is meant to be running; Pending is the tombstoned rows — | ||
| 983 | // deletes it has been asked for and not yet finished — and PendingVMs is how | ||
| 984 | // many rows those are. Held() is the sum, which is the number a placement has | ||
| 985 | // to beat; the split exists so a refusal can say which half is binding, because | ||
| 986 | // the two have different remedies (delete something, or wait). | ||
| 987 | type Commitment struct { | ||
| 988 | Live Alloc | ||
| 989 | Pending Alloc | ||
| 990 | PendingVMs int | ||
| 991 | } | ||
| 992 | |||
| 993 | // Held is what the host is holding in total: live VMs plus destroys in flight. | ||
| 994 | func (c Commitment) Held() Alloc { | ||
| 995 | return Alloc{ | ||
| 996 | VCPUs: c.Live.VCPUs + c.Pending.VCPUs, | ||
| 997 | MemMB: c.Live.MemMB + c.Pending.MemMB, | ||
| 998 | DiskGB: c.Live.DiskGB + c.Pending.DiskGB, | ||
| 999 | } | ||
| 1000 | } | ||
| 1001 | |||
| 981 | // CommittedOnHost sums the specs of every VM row still on a host — what the | 1002 | // CommittedOnHost sums the specs of every VM row still on a host — what the |
| 982 | // machine is holding, which is the number a placement decision has to beat. | 1003 | // machine is holding, which is the number a placement decision has to beat. |
| 983 | // | 1004 | // |
| @@ -989,16 +1010,31 @@ func allocatedByHost(q querier) (map[string]Alloc, error) { | |||
| 989 | // may still be shutting down, so its resources are not free to promise to | 1010 | // may still be shutting down, so its resources are not free to promise to |
| 990 | // somebody else. Reaped VMs need no predicate — their rows are gone. | 1011 | // somebody else. Reaped VMs need no predicate — their rows are gone. |
| 991 | // | 1012 | // |
| 1013 | // The two halves come back separately because counting a tombstone is right and | ||
| 1014 | // blaming the caller for it is not: a create refused by resources a delete has | ||
| 1015 | // not finished releasing has a different answer ("wait") than one refused by | ||
| 1016 | // VMs that are actually there ("delete something"), and only the split can tell | ||
| 1017 | // them apart. | ||
| 1018 | // | ||
| 992 | // allocatedByHost, which feeds the console's "allocated", counts live rows | 1019 | // allocatedByHost, which feeds the console's "allocated", counts live rows |
| 993 | // only, so during a teardown this reads higher. Erring that way is the safe | 1020 | // only, so during a teardown Held() reads higher. Erring that way is the safe |
| 994 | // direction for admission: refusing a VM for a bed that is being stripped costs | 1021 | // direction for admission: refusing a VM for a bed that is being stripped costs |
| 995 | // a retry, admitting one into it costs a failed VM. | 1022 | // a retry, admitting one into it costs a failed VM. |
| 996 | func (s *Store) CommittedOnHost(hostID string) (Alloc, error) { | 1023 | func (s *Store) CommittedOnHost(hostID string) (Commitment, error) { |
| 997 | var a Alloc | 1024 | var c Commitment |
| 998 | err := s.db.QueryRow(` | 1025 | err := s.db.QueryRow(` |
| 999 | SELECT COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0) | 1026 | SELECT COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN vcpus END),0), |
| 1000 | FROM vms WHERE host_id=?`, hostID).Scan(&a.VCPUs, &a.MemMB, &a.DiskGB) | 1027 | COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN mem_mb END),0), |
| 1001 | return a, err | 1028 | COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN disk_gb END),0), |
| 1029 | COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN vcpus END),0), | ||
| 1030 | COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN mem_mb END),0), | ||
| 1031 | COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN disk_gb END),0), | ||
| 1032 | COUNT(deleted_at) | ||
| 1033 | FROM vms WHERE host_id=?`, hostID).Scan( | ||
| 1034 | &c.Live.VCPUs, &c.Live.MemMB, &c.Live.DiskGB, | ||
| 1035 | &c.Pending.VCPUs, &c.Pending.MemMB, &c.Pending.DiskGB, | ||
| 1036 | &c.PendingVMs) | ||
| 1037 | return c, err | ||
| 1002 | } | 1038 | } |
| 1003 | 1039 | ||
| 1004 | // AuditEntry is one row of the append-only audit trail. | 1040 | // AuditEntry is one row of the append-only audit trail. |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -714,11 +714,14 @@ export type CapacityReading = { | |||
| 714 | * gauge needs. Past 90% the level turns, the same wall the fleet table's load | 714 | * gauge needs. Past 90% the level turns, the same wall the fleet table's load |
| 715 | * meter marks. | 715 | * meter marks. |
| 716 | * | 716 | * |
| 717 | * Over-allocation is a state the console has to be able to draw. Creating a VM | 717 | * Over-allocation is a state the console has to be able to draw, and a normal |
| 718 | * a host has no room for is refused now, but a host that re-reports a smaller | 718 | * one. A host that declares no cap advertises the machine's totals and its |
| 719 | * capacity (a disk shrank, an operator lowered the agent's cap) puts VMs that | 719 | * agent admits past them on purpose — sparse disks, memory that is not |
| 720 | * were placed honestly over the line, as do rows made before the refusal | 720 | * preallocated — so allocation over capacity is what deliberate overcommit |
| 721 | * existed. Rendering that as "-2 free" states it as a negative amount of room, | 721 | * looks like from here. A capped host is placed against its cap, but one that |
| 722 | * re-reports a smaller capacity (a disk shrank, an operator lowered the | ||
| 723 | * agent's cap) puts VMs that were placed honestly over the line just the same. | ||
| 724 | * Rendering that as "-2 free" states it as a negative amount of room, | ||
| 722 | * which is not a thing; "over by 2" is the same number said truthfully. | 725 | * which is not a thing; "over by 2" is the same number said truthfully. |
| 723 | * | 726 | * |
| 724 | * A total of 0 is a host that has reported no capacity, not a host with none — | 727 | * A total of 0 is a host that has reported no capacity, not a host with none — |