55e7bb7f
feat(server): a full host is not a place to put one
a73x 2026-08-11 10:38
Commit message
internal/mcpserver/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -145,6 +145,30 @@ func TestVMCreateSurfacesThePreCSRRefusal(t *testing.T) { | |||
| 145 | assert.Contains(t, err.Error(), "Upgrade that host's agent") | 145 | assert.Contains(t, err.Error(), "Upgrade that host's agent") |
| 146 | } | 146 | } |
| 147 | 147 | ||
| 148 | // The same is true of the capacity refusal, and more so: the numbers ARE the | ||
| 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. | ||
| 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. " + | ||
| 153 | "A host runs no more than it reports. Ask for less, delete a VM on that host to free what it holds, " + | ||
| 154 | "or create it on a host with room." | ||
| 155 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { | ||
| 156 | if r.URL.Path == "/api/v1/hosts" { | ||
| 157 | json.NewEncoder(w).Encode([]map[string]any{ | ||
| 158 | {"id": "h1", "name": "mewtwo", "online": true, "agent_version": "v0.0.5"}, | ||
| 159 | }) | ||
| 160 | return | ||
| 161 | } | ||
| 162 | http.Error(w, refusal, http.StatusConflict) | ||
| 163 | }) | ||
| 164 | tools := &Tools{API: c} | ||
| 165 | _, err := tools.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", VCPUs: 8}) | ||
| 166 | require.Error(t, err) | ||
| 167 | 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(), "create it on a host with room") | ||
| 170 | } | ||
| 171 | |||
| 148 | func TestAPIErrorSurfacesBodyNotToken(t *testing.T) { | 172 | func TestAPIErrorSurfacesBodyNotToken(t *testing.T) { |
| 149 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { | 173 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { |
| 150 | http.Error(w, "invalid name", http.StatusBadRequest) | 174 | http.Error(w, "invalid name", http.StatusBadRequest) |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -829,11 +829,39 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 829 | // serve one right now: the row is desired state, the agent that materializes | 829 | // serve one right now: the row is desired state, the agent that materializes |
| 830 | // it may well be a newer one, and vmssh still refuses the dial if the guest | 830 | // it may well be a newer one, and vmssh still refuses the dial if the guest |
| 831 | // it eventually boots has no certificate. | 831 | // it eventually boots has no certificate. |
| 832 | if st, ok := a.reg.Get(req.HostID); ok && st.Online && !release.CertifiesGuestHostKeys(st.AgentVersion) { | 832 | hostState, hostHasSpoken := a.reg.Get(req.HostID) |
| 833 | http.Error(w, precsrRefusal(host.Name, req.HostID, st.AgentVersion, a.URL(upgradeAgentPath(req.HostID))), http.StatusConflict) | 833 | hostHasSpoken = hostHasSpoken && hostState.Online |
| 834 | if hostHasSpoken && !release.CertifiesGuestHostKeys(hostState.AgentVersion) { | ||
| 835 | http.Error(w, precsrRefusal(host.Name, req.HostID, hostState.AgentVersion, a.URL(upgradeAgentPath(req.HostID))), http.StatusConflict) | ||
| 834 | return | 836 | return |
| 835 | } | 837 | } |
| 836 | 838 | ||
| 839 | // Capacity precondition, the third refusal of this same shape: the request | ||
| 840 | // is fine, and the host cannot serve it. A host that is already holding as | ||
| 841 | // much as it says it has cannot boot one more guest — the agent refuses it | ||
| 842 | // at materialization ("host capacity limit reached", see reconcile.Engine) | ||
| 843 | // and the VM ends up failed, minutes later, with an answer that existed | ||
| 844 | // here. So the create answers 409 now, naming the dimension and the numbers. | ||
| 845 | // | ||
| 846 | // Judged only on a host that has spoken, exactly like the refusal above and | ||
| 847 | // for the same reason: reported capacity is registry state, so an offline or | ||
| 848 | // never-reporting host has zeroes that say nothing about the machine. Such a | ||
| 849 | // host takes the create as desired state, and the agent's own admission | ||
| 850 | // check — which is anyway the authority for the races this cannot close — | ||
| 851 | // remains the backstop. | ||
| 852 | if hostHasSpoken { | ||
| 853 | held, err := a.st.CommittedOnHost(req.HostID) | ||
| 854 | if err != nil { | ||
| 855 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 856 | return | ||
| 857 | } | ||
| 858 | want := store.Alloc{VCPUs: req.VCPUs, MemMB: req.MemMB, DiskGB: req.DiskGB} | ||
| 859 | if msg := overCapacityRefusal(host.Name, req.HostID, want, held, hostState.Capacity); msg != "" { | ||
| 860 | http.Error(w, msg, http.StatusConflict) | ||
| 861 | return | ||
| 862 | } | ||
| 863 | } | ||
| 864 | |||
| 837 | // Install the SSH key into user-supplied cloud-init. When only one of the | 865 | // Install the SSH key into user-supplied cloud-init. When only one of the |
| 838 | // two is set the seed builder handles it (verbatim user-data, or the | 866 | // two is set the seed builder handles it (verbatim user-data, or the |
| 839 | // generated default template); it's the BOTH case that used to silently | 867 | // generated default template); it's the BOTH case that used to silently |
internal/server/api/capacity.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,46 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/a73x/eitri/internal/server/registry" | ||
| 8 | "github.com/a73x/eitri/internal/server/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // 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. | ||
| 13 | // | ||
| 14 | // 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 | ||
| 16 | // 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 | ||
| 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 | // | ||
| 22 | // Every dimension the host has spoken about is judged, and each binding one is | ||
| 23 | // named: a VM that is too big in two ways should be resized once, not twice. A | ||
| 24 | // dimension whose reported capacity is 0 is skipped — an agent reports totals, | ||
| 25 | // and a zero is a failed probe (or a report that predates the field), not a | ||
| 26 | // host with no memory. Judging on it would refuse every VM on the fleet. | ||
| 27 | func overCapacityRefusal(hostName, hostID string, want, held store.Alloc, cap registry.Capacity) string { | ||
| 28 | var over []string | ||
| 29 | dim := func(noun, unit string, want, held, capacity int64) { | ||
| 30 | if capacity <= 0 || held+want <= capacity { | ||
| 31 | return | ||
| 32 | } | ||
| 33 | over = append(over, fmt.Sprintf("%s — needs %d%s, already holds %d of %d%s", | ||
| 34 | noun, want, unit, held, capacity, unit)) | ||
| 35 | } | ||
| 36 | dim("vcpus", "", want.VCPUs, held.VCPUs, cap.VCPUs) | ||
| 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 "" | ||
| 41 | } | ||
| 42 | return fmt.Sprintf("host %s (%s) has no room for this VM: %s. A host runs no more than it reports, so the "+ | ||
| 43 | "agent there would refuse this VM at boot and it would sit failed. Ask for less, delete a VM on that "+ | ||
| 44 | "host to free what it holds, or create it on a host with room.", | ||
| 45 | hostName, hostID, strings.Join(over, "; ")) | ||
| 46 | } | ||
internal/server/api/capacity_api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,170 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "io" | ||
| 6 | "net/http" | ||
| 7 | "net/http/httptest" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | ||
| 12 | |||
| 13 | "github.com/a73x/eitri/internal/server/registry" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // agentReportsCapacity is the report of a host that has told this server what | ||
| 17 | // machine it is: the create path judges placement only on a host that has | ||
| 18 | // spoken, and this is what speaking sounds like. | ||
| 19 | func agentReportsCapacity(hostID string, vcpus, memMB, diskGB int64) { | ||
| 20 | testReg.UpdateReport(hostID, registry.Report{ | ||
| 21 | Capacity: registry.Capacity{VCPUs: vcpus, MemMB: memMB, DiskGB: diskGB}, | ||
| 22 | }) | ||
| 23 | } | ||
| 24 | |||
| 25 | // createVM posts one create and returns the response, so a test reads as the | ||
| 26 | // sequence of placements it is really making. | ||
| 27 | func createVM(t *testing.T, ts *httptest.Server, hostID, name string, vcpus, memMB, diskGB int64) *http.Response { | ||
| 28 | t.Helper() | ||
| 29 | return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 30 | "host_id": hostID, "name": name, | ||
| 31 | "vcpus": vcpus, "mem_mb": memMB, "disk_gb": diskGB, | ||
| 32 | }) | ||
| 33 | } | ||
| 34 | |||
| 35 | func bodyOf(t *testing.T, resp *http.Response) string { | ||
| 36 | t.Helper() | ||
| 37 | b, err := io.ReadAll(resp.Body) | ||
| 38 | require.NoError(t, err) | ||
| 39 | return string(b) | ||
| 40 | } | ||
| 41 | |||
| 42 | // TestCreateVMRefusesAHostWithNoRoom pins the third refusal of this shape: the | ||
| 43 | // request is fine and the host cannot serve it. Each dimension binds on its | ||
| 44 | // own, and the refusal has to be actionable on its own terms — which dimension, | ||
| 45 | // what the VM asked for, what the host is already holding out of what it has. | ||
| 46 | func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { | ||
| 47 | for _, tc := range []struct { | ||
| 48 | name string | ||
| 49 | vcpus, memMB, diskGB int64 | ||
| 50 | want string | ||
| 51 | }{ | ||
| 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"}, | ||
| 54 | {"disk", 1, 1024, 200, "disk — needs 200GB, already holds 10 of 100GB"}, | ||
| 55 | } { | ||
| 56 | t.Run(tc.name, func(t *testing.T) { | ||
| 57 | ts, st, _ := testServer(t) | ||
| 58 | out := enroll(t, ts) | ||
| 59 | agentReportsCapacity(out["host_id"], 6, 4096, 100) | ||
| 60 | |||
| 61 | // 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) | ||
| 63 | |||
| 64 | resp := createVM(t, ts, out["host_id"], "toobig", tc.vcpus, tc.memMB, tc.diskGB) | ||
| 65 | require.Equal(t, 409, resp.StatusCode) | ||
| 66 | msg := bodyOf(t, resp) | ||
| 67 | assert.Contains(t, msg, tc.want) | ||
| 68 | assert.Contains(t, msg, "host host-a ("+out["host_id"]+")", "the refusal must name the host") | ||
| 69 | assert.Contains(t, msg, "create it on a host with room", "the refusal must name a way out") | ||
| 70 | |||
| 71 | vms, err := st.ListVMs() | ||
| 72 | require.NoError(t, err) | ||
| 73 | require.Len(t, vms, 1, "a refused create must leave no row behind") | ||
| 74 | }) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | // TestCreateVMRefusalNamesEveryBindingDimension: a VM too big in two ways | ||
| 79 | // should be resized once, not discovered twice. | ||
| 80 | func TestCreateVMRefusalNamesEveryBindingDimension(t *testing.T) { | ||
| 81 | ts, _, _ := testServer(t) | ||
| 82 | out := enroll(t, ts) | ||
| 83 | agentReportsCapacity(out["host_id"], 6, 4096, 100) | ||
| 84 | |||
| 85 | resp := createVM(t, ts, out["host_id"], "huge", 8, 8192, 10) | ||
| 86 | require.Equal(t, 409, resp.StatusCode) | ||
| 87 | 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") | ||
| 90 | assert.NotContains(t, msg, "disk —", "a dimension that fits is not the caller's problem") | ||
| 91 | } | ||
| 92 | |||
| 93 | // TestCreateVMFillsAHostExactly is the boundary: capacity is a limit, not a | ||
| 94 | // threshold. A VM that fits the last of the host is placed, and the next one — | ||
| 95 | // however small — is refused, because there is nothing left. | ||
| 96 | func TestCreateVMFillsAHostExactly(t *testing.T) { | ||
| 97 | ts, _, _ := testServer(t) | ||
| 98 | out := enroll(t, ts) | ||
| 99 | agentReportsCapacity(out["host_id"], 4, 4096, 40) | ||
| 100 | |||
| 101 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "half", 2, 2048, 20).StatusCode) | ||
| 102 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "rest", 2, 2048, 20).StatusCode, | ||
| 103 | "a VM that exactly fills the host still fits") | ||
| 104 | |||
| 105 | resp := createVM(t, ts, out["host_id"], "onemore", 1, 1, 1) | ||
| 106 | require.Equal(t, 409, resp.StatusCode) | ||
| 107 | assert.Contains(t, bodyOf(t, resp), "vcpus — needs 1, already holds 4 of 4") | ||
| 108 | } | ||
| 109 | |||
| 110 | // TestCreateVMJudgesOnlyAHostThatHasReportedCapacity: reported capacity is | ||
| 111 | // registry state. A host that has never connected, one that has said hello and | ||
| 112 | // nothing since, and one whose report carries no capacity at all are all hosts | ||
| 113 | // this server knows nothing about the size of — and a zero read as "no room" | ||
| 114 | // would refuse every create on the fleet for the moment after a restart. All | ||
| 115 | // three take the create as desired state; the agent's own admission check is | ||
| 116 | // the backstop. | ||
| 117 | func TestCreateVMJudgesOnlyAHostThatHasReportedCapacity(t *testing.T) { | ||
| 118 | t.Run("never connected", func(t *testing.T) { | ||
| 119 | ts, st, _ := testServer(t) | ||
| 120 | out := enrollSilent(t, ts) | ||
| 121 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "pending", 64, 999999, 9999).StatusCode) | ||
| 122 | vms, _ := st.ListVMs() | ||
| 123 | assert.Len(t, vms, 1, "the create must land as desired state") | ||
| 124 | }) | ||
| 125 | |||
| 126 | t.Run("said hello, never reported", func(t *testing.T) { | ||
| 127 | ts, dbst, _ := testServer(t) | ||
| 128 | out := enroll(t, ts) // a Hello and nothing since: the registry knows the | ||
| 129 | // agent's version and not one thing about the machine | ||
| 130 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "pending", 64, 999999, 9999).StatusCode) | ||
| 131 | vms, _ := dbst.ListVMs() | ||
| 132 | assert.Len(t, vms, 1) | ||
| 133 | }) | ||
| 134 | |||
| 135 | t.Run("online but reporting no capacity", func(t *testing.T) { | ||
| 136 | ts, dbst, _ := testServer(t) | ||
| 137 | out := enroll(t, ts) | ||
| 138 | agentReports(out["host_id"]) // a report with no capacity in it | ||
| 139 | require.Equal(t, 201, createVM(t, ts, out["host_id"], "unjudged", 64, 999999, 9999).StatusCode) | ||
| 140 | vms, _ := dbst.ListVMs() | ||
| 141 | assert.Len(t, vms, 1) | ||
| 142 | }) | ||
| 143 | } | ||
| 144 | |||
| 145 | // TestCreateVMCountsATombstonedVMUntilItIsReaped is the predicate the whole | ||
| 146 | // refusal rests on. A tombstone is a destroy in progress: the disk is still on | ||
| 147 | // 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 | ||
| 149 | // abandoned sweep — frees them, and it frees them by removing the row. | ||
| 150 | func TestCreateVMCountsATombstonedVMUntilItIsReaped(t *testing.T) { | ||
| 151 | ts, dbst, _ := testServer(t) | ||
| 152 | out := enroll(t, ts) | ||
| 153 | agentReportsCapacity(out["host_id"], 4, 4096, 40) | ||
| 154 | |||
| 155 | resp := createVM(t, ts, out["host_id"], "first", 4, 4096, 40) | ||
| 156 | require.Equal(t, 201, resp.StatusCode) | ||
| 157 | var created map[string]string | ||
| 158 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) | ||
| 159 | |||
| 160 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+created["id"], testPAT, nil).StatusCode) | ||
| 161 | |||
| 162 | refused := createVM(t, ts, out["host_id"], "second", 4, 4096, 40) | ||
| 163 | 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") | ||
| 165 | |||
| 166 | // The agent acks the destroy: the row goes, and with it the commitment. | ||
| 167 | require.NoError(t, dbst.HardDeleteVM(created["id"])) | ||
| 168 | assert.Equal(t, 201, createVM(t, ts, out["host_id"], "second", 4, 4096, 40).StatusCode, | ||
| 169 | "a reaped VM holds nothing") | ||
| 170 | } | ||
internal/server/store/allocation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -58,6 +58,42 @@ func TestAllocatedByHostSeparatesHosts(t *testing.T) { | |||
| 58 | assert.Equal(t, int64(8), alloc[h2.ID].VCPUs) | 58 | assert.Equal(t, int64(8), alloc[h2.ID].VCPUs) |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | // TestCommittedOnHostCountsTombstonesUntilTheyAreReaped is the other half of | ||
| 62 | // the pair above, and the difference between them is the point. What the | ||
| 63 | // console calls "allocated" is what is live; what a placement has to beat is | ||
| 64 | // what the machine is still holding, and a tombstoned VM's disk is on the host | ||
| 65 | // until the reap deletes the row. | ||
| 66 | func TestCommittedOnHostCountsTombstonesUntilTheyAreReaped(t *testing.T) { | ||
| 67 | s := newStore(t) | ||
| 68 | h := enrollHost(t, s) | ||
| 69 | vmWithResources(t, s, h, "a", 2, 2048, 10) | ||
| 70 | dying := vmWithResources(t, s, h, "b", 4, 4096, 20) | ||
| 71 | require.NoError(t, s.TombstoneVM(dying.ID)) | ||
| 72 | |||
| 73 | held, err := s.CommittedOnHost(h.ID) | ||
| 74 | require.NoError(t, err) | ||
| 75 | assert.Equal(t, Alloc{VCPUs: 6, MemMB: 6144, DiskGB: 30}, held, | ||
| 76 | "a VM being destroyed still occupies the host") | ||
| 77 | |||
| 78 | require.NoError(t, s.HardDeleteVM(dying.ID)) | ||
| 79 | held, err = s.CommittedOnHost(h.ID) | ||
| 80 | require.NoError(t, err) | ||
| 81 | assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held, | ||
| 82 | "the reap is what frees it") | ||
| 83 | } | ||
| 84 | |||
| 85 | // TestCommittedOnHostSeparatesHosts: one host's commitment says nothing about | ||
| 86 | // another's, and a host with nothing on it holds nothing rather than erroring. | ||
| 87 | func TestCommittedOnHostSeparatesHosts(t *testing.T) { | ||
| 88 | s := newStore(t) | ||
| 89 | h := enrollHost(t, s) | ||
| 90 | vmWithResources(t, s, h, "a", 2, 2048, 10) | ||
| 91 | |||
| 92 | held, err := s.CommittedOnHost("no-such-host") | ||
| 93 | require.NoError(t, err) | ||
| 94 | assert.Equal(t, Alloc{}, held) | ||
| 95 | } | ||
| 96 | |||
| 61 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { | 97 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { |
| 62 | t.Helper() | 98 | t.Helper() |
| 63 | _, alloc, _, err := s.Snapshot() | 99 | _, alloc, _, err := s.Snapshot() |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -963,6 +963,29 @@ func allocatedByHost(q querier) (map[string]Alloc, error) { | |||
| 963 | return out, rows.Err() | 963 | return out, rows.Err() |
| 964 | } | 964 | } |
| 965 | 965 | ||
| 966 | // CommittedOnHost sums the specs of every VM row still on a host — what the | ||
| 967 | // machine is holding, which is the number a placement decision has to beat. | ||
| 968 | // | ||
| 969 | // It deliberately does NOT filter deleted_at, and that is the whole point of | ||
| 970 | // having it beside allocatedByHost. A tombstone is a destroy in progress, not a | ||
| 971 | // destroy: the row is marked, the agent is asked to tear the guest down, and | ||
| 972 | // only when it acks (or the abandoned sweep gives up on an offline host) is the | ||
| 973 | // row hard-deleted. Until that reap the disk is still on the host and the guest | ||
| 974 | // may still be shutting down, so its resources are not free to promise to | ||
| 975 | // somebody else. Reaped VMs need no predicate — their rows are gone. | ||
| 976 | // | ||
| 977 | // allocatedByHost, which feeds the console's "allocated", counts live rows | ||
| 978 | // only, so during a teardown this reads higher. Erring that way is the safe | ||
| 979 | // direction for admission: refusing a VM for a bed that is being stripped costs | ||
| 980 | // a retry, admitting one into it costs a failed VM. | ||
| 981 | func (s *Store) CommittedOnHost(hostID string) (Alloc, error) { | ||
| 982 | var a Alloc | ||
| 983 | err := s.db.QueryRow(` | ||
| 984 | SELECT COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0) | ||
| 985 | FROM vms WHERE host_id=?`, hostID).Scan(&a.VCPUs, &a.MemMB, &a.DiskGB) | ||
| 986 | return a, err | ||
| 987 | } | ||
| 988 | |||
| 966 | // AuditEntry is one row of the append-only audit trail. | 989 | // AuditEntry is one row of the append-only audit trail. |
| 967 | type AuditEntry struct { | 990 | type AuditEntry struct { |
| 968 | At time.Time | 991 | At time.Time |
web/src/lib/ResourceBar.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,6 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { capacityReading } from '$lib/fleet.svelte'; | ||
| 3 | |||
| 2 | let { | 4 | let { |
| 3 | label, | 5 | label, |
| 4 | used, | 6 | used, |
| @@ -6,22 +8,27 @@ | |||
| 6 | unit = '' | 8 | unit = '' |
| 7 | }: { label: string; used: number; total: number; unit?: string } = $props(); | 9 | }: { label: string; used: number; total: number; unit?: string } = $props(); |
| 8 | 10 | ||
| 9 | const pct = $derived(total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0); | 11 | const reading = $derived(capacityReading(used, total)); |
| 10 | const free = $derived(total > 0 ? total - used : 0); | ||
| 11 | const level = $derived(pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : 'ok'); | ||
| 12 | </script> | 12 | </script> |
| 13 | 13 | ||
| 14 | <div class="bar"> | 14 | <div class="bar"> |
| 15 | <div class="head"> | 15 | <div class="head"> |
| 16 | <span class="label">{label}</span> | 16 | <span class="label">{label}</span> |
| 17 | {#if total > 0} | 17 | {#if total > 0} |
| 18 | <span class="nums">{used} / {total}{unit} <span class="free">· {free}{unit} free</span></span> | 18 | <span class="nums"> |
| 19 | {used} / {total}{unit} | ||
| 20 | {#if reading.over > 0} | ||
| 21 | <span class="over">· over by {reading.over}{unit}</span> | ||
| 22 | {:else} | ||
| 23 | <span class="free">· {reading.free}{unit} free</span> | ||
| 24 | {/if} | ||
| 25 | </span> | ||
| 19 | {:else} | 26 | {:else} |
| 20 | <span class="nums dim">— (offline)</span> | 27 | <span class="nums dim">— (offline)</span> |
| 21 | {/if} | 28 | {/if} |
| 22 | </div> | 29 | </div> |
| 23 | <div class="track"> | 30 | <div class="track"> |
| 24 | <div class="fill {level}" style="width: {pct}%"></div> | 31 | <div class="fill {reading.level}" style="width: {reading.pct}%"></div> |
| 25 | </div> | 32 | </div> |
| 26 | </div> | 33 | </div> |
| 27 | 34 | ||
| @@ -45,6 +52,12 @@ | |||
| 45 | .dim { | 52 | .dim { |
| 46 | color: var(--faint); | 53 | color: var(--faint); |
| 47 | } | 54 | } |
| 55 | /* Over-allocation is not a quieter kind of "free": it is the one reading on | ||
| 56 | this panel worth looking at, so it sets in the same bold the fleet | ||
| 57 | table's load meter uses past 1.0. */ | ||
| 58 | .over { | ||
| 59 | font-weight: bold; | ||
| 60 | } | ||
| 48 | /* The same meter the fleet table uses, stretched to the panel: a wash track | 61 | /* The same meter the fleet table uses, stretched to the panel: a wash track |
| 49 | with an ink fill. The numbers above it are the reading; the bar is the | 62 | with an ink fill. The numbers above it are the reading; the bar is the |
| 50 | glance. Past 90% the fill turns, because that is a wall you can hit. */ | 63 | glance. Past 90% the fill turns, because that is a wall you can hit. */ |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -504,6 +504,44 @@ export function formatHostPort(addr: string, port: number): string { | |||
| 504 | return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`; | 504 | return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`; |
| 505 | } | 505 | } |
| 506 | 506 | ||
| 507 | /** CapacityReading is one allocation read against the capacity its host | ||
| 508 | * reports: what to draw, and what to say about the difference. */ | ||
| 509 | export type CapacityReading = { | ||
| 510 | /** pct fills the meter, clamped to 0–100 — a bar cannot overflow its track. */ | ||
| 511 | pct: number; | ||
| 512 | /** free is what is left, and it stops at zero. Below that is not less room; | ||
| 513 | * it is a different fact, and `over` is the one that states it. */ | ||
| 514 | free: number; | ||
| 515 | /** over is by how much the allocation exceeds capacity, 0 when it fits. */ | ||
| 516 | over: number; | ||
| 517 | level: 'ok' | 'warm' | 'hot'; | ||
| 518 | }; | ||
| 519 | |||
| 520 | /** capacityReading folds an allocation and a reported capacity into what a | ||
| 521 | * gauge needs. Past 90% the level turns, the same wall the fleet table's load | ||
| 522 | * meter marks. | ||
| 523 | * | ||
| 524 | * Over-allocation is a state the console has to be able to draw. Creating a VM | ||
| 525 | * a host has no room for is refused now, but a host that re-reports a smaller | ||
| 526 | * capacity (a disk shrank, an operator lowered the agent's cap) puts VMs that | ||
| 527 | * were placed honestly over the line, as do rows made before the refusal | ||
| 528 | * existed. Rendering that as "-2 free" states it as a negative amount of room, | ||
| 529 | * which is not a thing; "over by 2" is the same number said truthfully. | ||
| 530 | * | ||
| 531 | * A total of 0 is a host that has reported no capacity, not a host with none — | ||
| 532 | * the reading is empty and the caller says "offline" rather than drawing a | ||
| 533 | * full bar over an unknown. */ | ||
| 534 | export function capacityReading(used: number, total: number): CapacityReading { | ||
| 535 | if (total <= 0) return { pct: 0, free: 0, over: 0, level: 'ok' }; | ||
| 536 | const pct = Math.min(100, Math.round((used / total) * 100)); | ||
| 537 | return { | ||
| 538 | pct, | ||
| 539 | free: Math.max(0, total - used), | ||
| 540 | over: Math.max(0, used - total), | ||
| 541 | level: pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : 'ok' | ||
| 542 | }; | ||
| 543 | } | ||
| 544 | |||
| 507 | /** eventLabel maps a lifecycle VMEvent to a human label, folding in the one | 545 | /** eventLabel maps a lifecycle VMEvent to a human label, folding in the one |
| 508 | * detail that matters per action. detail arrives already decoded (raw JSON on | 546 | * detail that matters per action. detail arrives already decoded (raw JSON on |
| 509 | * the wire); guard the shape defensively rather than parsing. */ | 547 | * the wire); guard the shape defensively rather than parsing. */ |
web/src/lib/fleet.test.ts
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | import { beforeEach, describe, expect, test } from 'vitest'; | 1 | import { beforeEach, describe, expect, test } from 'vitest'; |
| 2 | import { fleet, vmTrustStale, type UserCA, type VM } from './fleet.svelte'; | 2 | import { capacityReading, fleet, vmTrustStale, type UserCA, type VM } from './fleet.svelte'; |
| 3 | import type { components } from './api-types'; | 3 | import type { components } from './api-types'; |
| 4 | 4 | ||
| 5 | type TrustedCA = components['schemas']['TrustedCA']; | 5 | type TrustedCA = components['schemas']['TrustedCA']; |
| @@ -28,7 +28,6 @@ function vmTrusting(trusted_cas: TrustedCA[] | null): VM { | |||
| 28 | lifecycle: 'ready', | 28 | lifecycle: 'ready', |
| 29 | mem_mb: 1024, | 29 | mem_mb: 1024, |
| 30 | name: 'guest', | 30 | name: 'guest', |
| 31 | persistent: false, | ||
| 32 | phase: 'ready', | 31 | phase: 'ready', |
| 33 | power_state: 'running', | 32 | power_state: 'running', |
| 34 | status: 'ready', | 33 | status: 'ready', |
| @@ -88,3 +87,30 @@ describe('vmTrustStale', () => { | |||
| 88 | ).toBe(false); | 87 | ).toBe(false); |
| 89 | }); | 88 | }); |
| 90 | }); | 89 | }); |
| 90 | |||
| 91 | describe('capacityReading', () => { | ||
| 92 | test('a half-full host has room and a calm level', () => { | ||
| 93 | expect(capacityReading(2, 4)).toEqual({ pct: 50, free: 2, over: 0, level: 'ok' }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | test('past 90% the level turns, the same wall the load meter marks', () => { | ||
| 97 | expect(capacityReading(7, 10).level).toBe('warm'); | ||
| 98 | expect(capacityReading(9, 10).level).toBe('hot'); | ||
| 99 | }); | ||
| 100 | |||
| 101 | test('a full host is full, not over', () => { | ||
| 102 | expect(capacityReading(6, 6)).toEqual({ pct: 100, free: 0, over: 0, level: 'hot' }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | test('an over-allocated host says by how much, and never a negative free', () => { | ||
| 106 | expect(capacityReading(8, 6)).toEqual({ pct: 100, free: 0, over: 2, level: 'hot' }); | ||
| 107 | }); | ||
| 108 | |||
| 109 | test('the meter cannot overflow its track', () => { | ||
| 110 | expect(capacityReading(600, 6).pct).toBe(100); | ||
| 111 | }); | ||
| 112 | |||
| 113 | test('a host that has reported no capacity reads empty, not full', () => { | ||
| 114 | expect(capacityReading(4, 0)).toEqual({ pct: 0, free: 0, over: 0, level: 'ok' }); | ||
| 115 | }); | ||
| 116 | }); | ||
web/src/routes/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -16,6 +16,7 @@ | |||
| 16 | deleteConfirm, | 16 | deleteConfirm, |
| 17 | upgradeAgent, | 17 | upgradeAgent, |
| 18 | refreshUserCAs, | 18 | refreshUserCAs, |
| 19 | capacityReading, | ||
| 19 | type CreateVMRequest, | 20 | type CreateVMRequest, |
| 20 | type VM | 21 | type VM |
| 21 | } from '$lib/fleet.svelte'; | 22 | } from '$lib/fleet.svelte'; |
| @@ -281,9 +282,15 @@ | |||
| 281 | </td> | 282 | </td> |
| 282 | <td class="num">{vmCountByHost.get(h.id) ?? 0}</td> | 283 | <td class="num">{vmCountByHost.get(h.id) ?? 0}</td> |
| 283 | <td>{h.bridge_cidr || '—'}</td> | 284 | <td>{h.bridge_cidr || '—'}</td> |
| 284 | <td class="num">{h.allocated.vcpus}/{h.capacity.vcpus || '?'}</td> | 285 | <td class="num" class:over={capacityReading(h.allocated.vcpus, h.capacity.vcpus).over > 0} |
| 285 | <td class="num">{h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}</td> | 286 | >{h.allocated.vcpus}/{h.capacity.vcpus || '?'}</td |
| 286 | <td class="num">{h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}</td> | 287 | > |
| 288 | <td class="num" class:over={capacityReading(h.allocated.mem_mb, h.capacity.mem_mb).over > 0} | ||
| 289 | >{h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}</td | ||
| 290 | > | ||
| 291 | <td class="num" class:over={capacityReading(h.allocated.disk_gb, h.capacity.disk_gb).over > 0} | ||
| 292 | >{h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}</td | ||
| 293 | > | ||
| 287 | <td> | 294 | <td> |
| 288 | {#if h.status !== 'decommissioning'} | 295 | {#if h.status !== 'decommissioning'} |
| 289 | <button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button> | 296 | <button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button> |
| @@ -464,7 +471,12 @@ | |||
| 464 | display: inline-block; | 471 | display: inline-block; |
| 465 | font-variant-numeric: tabular-nums; | 472 | font-variant-numeric: tabular-nums; |
| 466 | } | 473 | } |
| 467 | .load.over { | 474 | /* One mark for "past the line", wherever a line exists: a host past 1.0 load, |
| 475 | and an allocation column past the capacity its host reports. The latter | ||
| 476 | can happen without a create being at fault — a host that re-reports a | ||
| 477 | smaller machine puts VMs placed honestly over it. */ | ||
| 478 | .load.over, | ||
| 479 | td.num.over { | ||
| 468 | font-weight: bold; | 480 | font-weight: bold; |
| 469 | } | 481 | } |
| 470 | .meter { | 482 | .meter { |
web/src/routes/hosts/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -100,8 +100,8 @@ | |||
| 100 | <ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" /> | 100 | <ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" /> |
| 101 | </div> | 101 | </div> |
| 102 | <p class="note"> | 102 | <p class="note"> |
| 103 | Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online). | 103 | Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online). All |
| 104 | vCPU is commonly oversubscribed; memory and disk are hard limits. | 104 | three are limits at placement: a create that would put any of them over capacity is refused. |
| 105 | </p> | 105 | </p> |
| 106 | 106 | ||
| 107 | {#if host.online && host.metrics} | 107 | {#if host.online && host.metrics} |