internal/server/api/capacity.go
Ref: Size: 7.1 KiB History
package api
import (
"fmt"
"strings"
"github.com/a73x/eitri/internal/server/registry"
"github.com/a73x/eitri/internal/server/store"
)
// declaredLimit answers the only question this refusal may rest on: is the
// number this host advertised for a dimension a limit its operator declared, or
// is it just the size of the machine? It returns the limit when the report
// proves the first, and 0 — "nothing proven, do not judge" — otherwise.
//
// The proof is arithmetic on the host's own report. An agent advertises the
// machine's totals clamped to its --max-vcpus/--max-mem-mb/--max-disk-gb flags
// (syncclient.advertisedCapacity), and the clamp only ever lowers, so an
// advertisement BELOW the machine's total can only be a configured cap. The
// machine's total is not on the wire as such, but the metrics in the same
// report imply it: the agent computes used as total-minus-available, so
// mem_used_mb + mem_available_mb is exactly the machine's memory, and
// disk_used_gb + disk_free_gb is the state dir's filesystem minus its reserved
// blocks — an UNDERCOUNT, which is the safe direction here (it can only fail to
// prove a cap, never invent one).
//
// This is a coupling to hostinfo.readMetrics on both platforms, and an
// invariant test there guards it (TestMetricsImplyTheCapacityTheSameProbeSees).
// If that identity is ever broken the failure is a refusal that stops firing,
// not one that fires wrongly.
//
// Everything unprovable reads as uncapped, and the list is worth naming: vCPUs
// always (no report carries the machine's core count, so a --max-vcpus cap is
// invisible here), a dimension whose probe failed and reports zeroes, and a cap
// set at exactly the machine's total. Each of those falls back to the world
// before this preflight existed — the agent refuses at boot and the VM sits
// failed — which is the honest cost of not refusing a VM the host would have
// run. See overCapacityRefusal for why that asymmetry is the whole design.
func declaredLimit(advertised, machineTotal int64) int64 {
if advertised <= 0 || machineTotal <= 0 || advertised >= machineTotal {
return 0
}
return advertised
}
// dimension is one resource under judgement: what the VM asked for, what the
// host is holding (split live vs. still-being-torn-down), and the limit — 0
// when this dimension is not judged at all.
type dimension struct {
noun, unit string
want, live, pending int64
limit int64
}
func (d dimension) overWithPending() bool { return d.limit > 0 && d.live+d.pending+d.want > d.limit }
func (d dimension) overLiveAlone() bool { return d.limit > 0 && d.live+d.want > d.limit }
// held phrases the binding numbers the way an operator has to act on them:
// what was asked for, what is already there, out of what.
func (d dimension) held() string {
return fmt.Sprintf("%s — needs %d%s, already holds %d of %d%s",
d.noun, d.want, d.unit, d.live+d.pending, d.limit, d.unit)
}
// heldWithPending is the same line for a refusal a teardown is tipping: the
// share held by VMs on their way out is the part that is about to go away.
func (d dimension) heldWithPending() string {
return fmt.Sprintf("%s, %d%s of it by the teardown", d.held(), d.pending, d.unit)
}
// overCapacityRefusal explains why a host cannot be given a VM: it is already
// holding as much as its operator said it may. It returns "" when the VM fits,
// and — deliberately — whenever this server cannot prove the host would refuse.
//
// The answer exists at create — the host's capacity and everything already on
// it are both known here — so the caller learns it in milliseconds instead of
// watching a VM sit `creating` and then `failed` because the agent reached the
// same conclusion minutes later, at the far end of an image download.
//
// The agent's admission is the AUTHORITY; this is a courtesy that runs first,
// and a courtesy must never be stricter than the authority it anticipates. The
// agent refuses a boot only against a configured cap (reconcile.quotaCheckLocked
// — with no --max-* flag it admits whatever it is asked, and that is the design:
// guest disks are sparse and memory is not preallocated, so a host deliberately
// runs more than it has on paper). So this refuses only where declaredLimit can
// prove a cap exists, and stays silent everywhere else. Refusing a VM the host
// would have run is the one failure with no remedy short of an ssh session;
// missing one it will refuse costs the caller the wait it always used to cost.
//
// Every judged dimension that binds is named: a VM that is too big in two ways
// should be resized once, not twice.
func overCapacityRefusal(hostName, hostID string, want store.Alloc, held store.Commitment, rep registry.Report) string {
m := rep.Metrics
dims := []dimension{
{noun: "vcpus", want: want.VCPUs, live: held.Live.VCPUs, pending: held.Pending.VCPUs,
limit: declaredLimit(rep.Capacity.VCPUs, 0)},
{noun: "memory", unit: "MB", want: want.MemMB, live: held.Live.MemMB, pending: held.Pending.MemMB,
limit: declaredLimit(rep.Capacity.MemMB, m.MemUsedMB+m.MemAvailableMB)},
{noun: "disk", unit: "GB", want: want.DiskGB, live: held.Live.DiskGB, pending: held.Pending.DiskGB,
limit: declaredLimit(rep.Capacity.DiskGB, m.DiskUsedGB+m.DiskFreeGB)},
}
var binding []dimension
liveAloneFits := true
for _, d := range dims {
if d.overWithPending() {
binding = append(binding, d)
}
if d.overLiveAlone() {
liveAloneFits = false
}
}
if len(binding) == 0 {
return ""
}
// A destroy in flight is the reason, and "delete a VM" would be a lie: the
// caller may well have just deleted one, and is being refused by the very
// resources that delete has not finished releasing. Say what is actually
// happening and give the remedy that matches it — the wait ends by itself.
if liveAloneFits {
var lines []string
for _, d := range binding {
lines = append(lines, d.heldWithPending())
}
return fmt.Sprintf("host %s (%s) has no room for this VM yet: %s. %s, and a deleted VM holds its "+
"vcpus, memory and disk until its host finishes tearing it down — the VMs still running there "+
"would leave room for this one. Wait for the teardown to finish and create it again, ask for "+
"less, or create it on a host with room.",
hostName, hostID, strings.Join(lines, "; "), pendingTeardowns(held.PendingVMs))
}
var lines []string
for _, d := range binding {
lines = append(lines, d.held())
}
return fmt.Sprintf("host %s (%s) has no room for this VM: %s. That is a limit its operator set below "+
"what the machine has, so the agent there would refuse this VM at boot and it would sit failed. "+
"Ask for less, delete a VM on that host to free what it holds, or create it on a host with room.",
hostName, hostID, strings.Join(lines, "; "))
}
// pendingTeardowns names the deletes still in flight, counted, so the caller
// can tell "the one I just deleted" from "somebody else is clearing the host".
func pendingTeardowns(n int) string {
if n == 1 {
return "1 VM deleted from that host is still being torn down"
}
return fmt.Sprintf("%d VMs deleted from that host are still being torn down", n)
}