a73x

c4ec7226

feat(observability): a creating VM says what it is doing, and an exposure counts its sessions

a73x   2026-08-11 18:41

Commit message
feat(observability): a creating VM says what it is doing, and an exposure counts its sessions

A create is minutes of image, disk, seed and boot behind one word, and only
the host can see which of those minutes a VM is sitting in. It says so now:
each step of a create publishes a sentence — "downloading image 1.2/3.7 GiB",
"preparing root disk", "booting" — on the VM's own report row, and the console
prints it under the status. The download counts itself out as it goes, both
halves scaled by one unit so the pair reads as a fraction.

The row is the change underneath. A pass published only when it ENDED, so a
first create — the slowest, most opaque thing a host does — was a report with
no row for that VM at all. A worker can now publish from inside a running pass,
carrying the same address and the same guest host key its final row would, so
nothing level-triggered is dropped for a tick.

A published port keeps three numbers: what it holds right now, and how many
callers it has turned away at its cap or could not carry to the guest. The two
totals are cumulative since the agent started, because what they count is
momentary — a gauge sampled once per report reads zero between bursts, and a
port quietly refusing callers looks exactly like a healthy one. The console
labels them so, and sessions leave debug lines in the agent's log as they open,
close, and are refused.

Absence stays readable at every hop. status_detail is a new field an older
agent never sends, and empty means only "nothing to add" — the console renders
what it always did. The counters are a MESSAGE, so an exposure nobody counted
is null rather than a row of zeros: "nobody said" and "nothing has happened"
are different answers, and only the second is a fact worth printing.

docs/openapi.json
Old New
@@ -322,6 +322,16 @@
322 "scope": { 322 "scope": {
323 "type": "string" 323 "type": "string"
324 }, 324 },
325 "sessions": {
326 "anyOf": [
327 {
328 "$ref": "#/components/schemas/ExposureSessions"
329 },
330 {
331 "type": "null"
332 }
333 ]
334 },
325 "state": { 335 "state": {
326 "type": "string" 336 "type": "string"
327 }, 337 },
@@ -344,6 +354,25 @@
344 ], 354 ],
345 "type": "object" 355 "type": "object"
346 }, 356 },
357 "ExposureSessions": {
358 "properties": {
359 "active": {
360 "type": "integer"
361 },
362 "dropped": {
363 "type": "integer"
364 },
365 "refused": {
366 "type": "integer"
367 }
368 },
369 "required": [
370 "active",
371 "dropped",
372 "refused"
373 ],
374 "type": "object"
375 },
347 "Host": { 376 "Host": {
348 "properties": { 377 "properties": {
349 "agent_update_available": { 378 "agent_update_available": {
@@ -778,6 +807,9 @@
778 "status": { 807 "status": {
779 "type": "string" 808 "type": "string"
780 }, 809 },
810 "status_detail": {
811 "type": "string"
812 },
781 "trusted_cas": { 813 "trusted_cas": {
782 "items": { 814 "items": {
783 "$ref": "#/components/schemas/TrustedCA" 815 "$ref": "#/components/schemas/TrustedCA"
@@ -808,6 +840,7 @@
808 "phase", 840 "phase",
809 "power_state", 841 "power_state",
810 "status", 842 "status",
843 "status_detail",
811 "vcpus" 844 "vcpus"
812 ], 845 ],
813 "type": "object" 846 "type": "object"
internal/agent/exposeproxy/exposeproxy.go
Old New
@@ -111,6 +111,15 @@ type exposure struct {
111 reason string 111 reason string
112 conns atomic.Int64 112 conns atomic.Int64
113 113
114 // refused and dropped are running totals for the life of this entry, which
115 // is the life of the exposure on this agent: an id the fleet still wants
116 // keeps its entry across rebinds, and an agent restart rebuilds every socket
117 // from the first snapshot and starts both at zero. Totals rather than
118 // gauges because what they count is momentary — see ExposureSessions in the
119 // proto.
120 refused atomic.Int64
121 dropped atomic.Int64
122
114 // sessions is the UDP session table, guarded by its own mutex because the 123 // sessions is the UDP session table, guarded by its own mutex because the
115 // packet loop touches it on every datagram while the manager lock is held 124 // packet loop touches it on every datagram while the manager lock is held
116 // across whole converges. nil until the first UDP bind. 125 // across whole converges. nil until the first UDP bind.
@@ -118,6 +127,23 @@ type exposure struct {
118 sessions map[string]*udpSession 127 sessions map[string]*udpSession
119 } 128 }
120 129
130 // counters is what this exposure has carried, for the report. active is read
131 // from whichever half of the pipe this exposure is: a TCP exposure holds
132 // connections, a UDP one holds sessions, and no exposure is both.
133 func (e *exposure) counters() *pb.ExposureSessions {
134 active := e.conns.Load()
135 if e.pc != nil {
136 e.smu.Lock()
137 active = int64(len(e.sessions))
138 e.smu.Unlock()
139 }
140 return &pb.ExposureSessions{
141 Active: active,
142 Refused: e.refused.Load(),
143 Dropped: e.dropped.Load(),
144 }
145 }
146
121 // bound reports whether this exposure currently holds a socket. 147 // bound reports whether this exposure currently holds a socket.
122 func (e *exposure) bound() bool { return e.ln != nil || e.pc != nil } 148 func (e *exposure) bound() bool { return e.ln != nil || e.pc != nil }
123 149
@@ -188,7 +214,7 @@ func (m *Manager) Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual {
188 if !ex.bound() { 214 if !ex.bound() {
189 if err := m.bind(d, ex); err != nil { 215 if err := m.bind(d, ex); err != nil {
190 ex.reason = err.Error() 216 ex.reason = err.Error()
191 out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "failed", Reason: ex.reason}) 217 out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "failed", Reason: ex.reason, Sessions: ex.counters()})
192 continue 218 continue
193 } 219 }
194 } 220 }
@@ -201,7 +227,7 @@ func (m *Manager) Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual {
201 if ex.pc != nil { 227 if ex.pc != nil {
202 ex.evictMovedSessions(m.addr(d.GetVmId())) 228 ex.evictMovedSessions(m.addr(d.GetVmId()))
203 } 229 }
204 out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "active", Reason: ex.reason}) 230 out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "active", Reason: ex.reason, Sessions: ex.counters()})
205 } 231 }
206 return out 232 return out
207 } 233 }
@@ -303,7 +329,9 @@ func (m *Manager) accept(id string, ex *exposure, ln net.Listener, vmID string,
303 // a stale count and all pass. 329 // a stale count and all pass.
304 if ex.conns.Add(1) > m.maxConns { 330 if ex.conns.Add(1) > m.maxConns {
305 ex.conns.Add(-1) 331 ex.conns.Add(-1)
332 ex.refused.Add(1)
306 conn.Close() 333 conn.Close()
334 slog.Debug("exposure refused a connection at its cap", "exposure", id, "cap", m.maxConns, "client", conn.RemoteAddr())
307 if !capped { 335 if !capped {
308 capped = true 336 capped = true
309 slog.Warn("exposure at its connection cap, refusing", "exposure", id, "cap", m.maxConns) 337 slog.Warn("exposure at its connection cap, refusing", "exposure", id, "cap", m.maxConns)
@@ -311,9 +339,11 @@ func (m *Manager) accept(id string, ex *exposure, ln net.Listener, vmID string,
311 continue 339 continue
312 } 340 }
313 capped = false 341 capped = false
342 slog.Debug("exposure connection opened", "exposure", id, "client", conn.RemoteAddr())
314 go func() { 343 go func() {
315 defer ex.conns.Add(-1) 344 defer ex.conns.Add(-1)
316 m.pipe(conn, vmID, guestPort) 345 m.pipe(ex, conn, vmID, guestPort)
346 slog.Debug("exposure connection closed", "exposure", id, "client", conn.RemoteAddr())
317 }() 347 }()
318 } 348 }
319 } 349 }
@@ -343,15 +373,19 @@ func outOfDescriptors(err error) bool {
343 // pipe connects one accepted connection to the guest. No address (a guest still 373 // pipe connects one accepted connection to the guest. No address (a guest still
344 // leasing) or a guest that will not answer closes immediately: the host half of 374 // leasing) or a guest that will not answer closes immediately: the host half of
345 // the pipe exists, the guest half does not, and waiting would only hold the 375 // the pipe exists, the guest half does not, and waiting would only hold the
346 // caller open on a promise nothing is keeping. 376 // caller open on a promise nothing is keeping. Both are counted as drops — from
347 func (m *Manager) pipe(client net.Conn, vmID string, guestPort uint32) { 377 // the caller's side the port accepted and then said nothing, and the exposure's
378 // own totals are the only place that difference is written down.
379 func (m *Manager) pipe(ex *exposure, client net.Conn, vmID string, guestPort uint32) {
348 ip := m.addr(vmID) 380 ip := m.addr(vmID)
349 if ip == "" { 381 if ip == "" {
382 ex.dropped.Add(1)
350 client.Close() 383 client.Close()
351 return 384 return
352 } 385 }
353 guest, err := net.Dial("tcp", net.JoinHostPort(ip, strconv.Itoa(int(guestPort)))) 386 guest, err := net.Dial("tcp", net.JoinHostPort(ip, strconv.Itoa(int(guestPort))))
354 if err != nil { 387 if err != nil {
388 ex.dropped.Add(1)
355 client.Close() 389 client.Close()
356 return 390 return
357 } 391 }
internal/agent/exposeproxy/exposeproxy_test.go
Old New
@@ -593,3 +593,96 @@ func TestStopAllClosesEveryListener(t *testing.T) {
593 assert.Error(t, err, "listener %s survived StopAll", addr) 593 assert.Error(t, err, "listener %s survived StopAll", addr)
594 } 594 }
595 } 595 }
596
597 // counters converges the same desired set again and returns what the named
598 // exposure reports about what it has carried. Converge is level-triggered, so
599 // asking twice is how the report is taken in production too.
600 func counters(t *testing.T, m *Manager, d []*pb.ExposureDesired, id string) *pb.ExposureSessions {
601 t.Helper()
602 for _, a := range m.Converge(d) {
603 if a.GetId() == id {
604 require.NotNil(t, a.GetSessions(), "exposure %q reported no counters", id)
605 return a.GetSessions()
606 }
607 }
608 t.Fatalf("exposure %q not in the report", id)
609 return nil
610 }
611
612 // TestExposureCountsWhatItHoldsAndWhatItTurnsAway is the observability leg: a
613 // published port that is quietly refusing callers looks exactly like a healthy
614 // one from outside, and these three numbers are the difference.
615 func TestExposureCountsWhatItHoldsAndWhatItTurnsAway(t *testing.T) {
616 g := newHoldingGuest(t)
617 m := newTestManager(t, map[string]string{"vm1": g.addr})
618 m.maxConns = 1
619 spec := []*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}
620 m.Converge(spec)
621 addr := boundPort(t, m, "e1")
622
623 held := hold(t, addr)
624 waitConns(t, m, "e1", 1)
625
626 got := counters(t, m, spec, "e1")
627 assert.Equal(t, int64(1), got.GetActive())
628 assert.Zero(t, got.GetRefused(), "nobody has been turned away yet")
629
630 // One caller past the cap, refused and closed.
631 over, err := net.Dial("tcp", addr)
632 require.NoError(t, err)
633 defer over.Close()
634 require.NoError(t, over.SetDeadline(time.Now().Add(5*time.Second)))
635 _, err = io.ReadAll(over)
636 require.NoError(t, err)
637
638 got = counters(t, m, spec, "e1")
639 assert.Equal(t, int64(1), got.GetActive(), "the refusal did not disturb the connection under the cap")
640 assert.Equal(t, int64(1), got.GetRefused())
641
642 // The refusal is the whole point of a TOTAL: once the port frees up, a gauge
643 // would read zero of everything and the burst would be gone.
644 held.Close()
645 waitConns(t, m, "e1", 0)
646 got = counters(t, m, spec, "e1")
647 assert.Zero(t, got.GetActive(), "active is a gauge and comes back down")
648 assert.Equal(t, int64(1), got.GetRefused(), "a refusal already counted is not un-counted")
649 }
650
651 // TestExposureCountsACallerItCannotCarry separates the port's own limit from the
652 // guest not being there: an operator reading "refused" should raise the cap, and
653 // one reading "dropped" should look at the guest.
654 func TestExposureCountsACallerItCannotCarry(t *testing.T) {
655 m := newTestManager(t, map[string]string{}) // no address for vm1: still leasing
656 spec := []*pb.ExposureDesired{desired("e1", "vm1", 8080, 0)}
657 m.Converge(spec)
658 addr := boundPort(t, m, "e1")
659
660 c, err := net.Dial("tcp", addr)
661 require.NoError(t, err)
662 defer c.Close()
663 require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second)))
664 _, err = io.ReadAll(c)
665 require.NoError(t, err)
666 waitConns(t, m, "e1", 0)
667
668 got := counters(t, m, spec, "e1")
669 assert.Equal(t, int64(1), got.GetDropped(), "a caller the host could not pipe to the guest is a drop")
670 assert.Zero(t, got.GetRefused(), "the cap refused nobody — the guest was not there")
671 }
672
673 // TestAFailedExposureStillReportsItsCounters: a port whose bind failed carries
674 // the message anyway, so "this agent counts" and "this port has counted
675 // nothing" stay one answer apart from "nobody said" for every exposure alike.
676 func TestAFailedExposureStillReportsItsCounters(t *testing.T) {
677 held, err := net.Listen("tcp", "127.0.0.1:0")
678 require.NoError(t, err)
679 defer held.Close()
680
681 m := newTestManager(t, map[string]string{"vm1": "127.0.0.1"})
682 port := portOf(t, held.Addr())
683 out := m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", 8080, port)})
684 require.Len(t, out, 1)
685 require.Equal(t, "failed", out[0].GetState())
686 require.NotNil(t, out[0].GetSessions())
687 assert.Zero(t, out[0].GetSessions().GetActive())
688 }
internal/agent/exposeproxy/udp.go
Old New
@@ -202,12 +202,14 @@ func (m *Manager) serve(id string, ex *exposure, pc *net.UDPConn, vmID string, g
202 202
203 s := ex.session(client.String()) 203 s := ex.session(client.String())
204 if s == nil { 204 if s == nil {
205 s = m.openSession(ex, pc, client, vmID, guestPort, &full) 205 s = m.openSession(id, ex, pc, client, vmID, guestPort, &full)
206 if s == nil { 206 if s == nil {
207 continue 207 continue
208 } 208 }
209 } 209 }
210 if _, err := s.guest.Write(buf[:n]); err != nil { 210 if _, err := s.guest.Write(buf[:n]); err != nil {
211 ex.dropped.Add(1)
212 slog.Debug("exposure session torn down, send to guest failed", "exposure", id, "client", s.key, "err", err)
211 ex.dropSession(s) 213 ex.dropSession(s)
212 continue 214 continue
213 } 215 }
@@ -220,22 +222,32 @@ func (m *Manager) serve(id string, ex *exposure, pc *net.UDPConn, vmID string, g
220 // datagram that asked for it is to be dropped — no guest address, a socket the 222 // datagram that asked for it is to be dropped — no guest address, a socket the
221 // OS would not give, or a table with no room — with full carrying the 223 // OS would not give, or a table with no room — with full carrying the
222 // at-capacity streak so a saturated port logs once rather than per packet. 224 // at-capacity streak so a saturated port logs once rather than per packet.
223 func (m *Manager) openSession(ex *exposure, pc *net.UDPConn, client *net.UDPAddr, vmID string, guestPort uint32, full *bool) *udpSession { 225 //
226 // The two ways of returning nil are counted apart, because they are different
227 // answers to an operator's question. A table at its cap is this proxy's own
228 // limit and says raise it; everything else here is the guest not being
229 // reachable yet, and says look at the guest.
230 func (m *Manager) openSession(id string, ex *exposure, pc *net.UDPConn, client *net.UDPAddr, vmID string, guestPort uint32, full *bool) *udpSession {
224 ip := m.addr(vmID) 231 ip := m.addr(vmID)
225 if ip == "" { 232 if ip == "" {
233 ex.dropped.Add(1)
226 return nil 234 return nil
227 } 235 }
228 guestAddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(ip, strconv.Itoa(int(guestPort)))) 236 guestAddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(ip, strconv.Itoa(int(guestPort))))
229 if err != nil { 237 if err != nil {
238 ex.dropped.Add(1)
230 return nil 239 return nil
231 } 240 }
232 guest, err := net.DialUDP("udp4", nil, guestAddr) 241 guest, err := net.DialUDP("udp4", nil, guestAddr)
233 if err != nil { 242 if err != nil {
243 ex.dropped.Add(1)
234 return nil 244 return nil
235 } 245 }
236 s := newUDPSession(client, guest, ip, m.unrepliedIdle, m.repliedIdle) 246 s := newUDPSession(client, guest, ip, m.unrepliedIdle, m.repliedIdle)
237 if !ex.addSession(s, m.maxSessions) { 247 if !ex.addSession(s, m.maxSessions) {
238 s.close() 248 s.close()
249 ex.refused.Add(1)
250 slog.Debug("exposure refused a session at its cap", "exposure", id, "cap", m.maxSessions, "client", client.String())
239 if !*full { 251 if !*full {
240 *full = true 252 *full = true
241 slog.Warn("exposure at its UDP session cap, dropping", "cap", m.maxSessions, "client", client.String()) 253 slog.Warn("exposure at its UDP session cap, dropping", "cap", m.maxSessions, "client", client.String())
@@ -243,7 +255,8 @@ func (m *Manager) openSession(ex *exposure, pc *net.UDPConn, client *net.UDPAddr
243 return nil 255 return nil
244 } 256 }
245 *full = false 257 *full = false
246 go m.relay(ex, pc, s) 258 slog.Debug("exposure session opened", "exposure", id, "client", client.String(), "guest", guestAddr.String())
259 go m.relay(id, ex, pc, s)
247 return s 260 return s
248 } 261 }
249 262
@@ -257,8 +270,11 @@ func (m *Manager) openSession(ex *exposure, pc *net.UDPConn, client *net.UDPAddr
257 // thing that needs to notice an expired session is the goroutine already 270 // thing that needs to notice an expired session is the goroutine already
258 // waiting on it. A deadline that fires early because the forward direction 271 // waiting on it. A deadline that fires early because the forward direction
259 // moved the session on is simply re-armed against the newer expiry. 272 // moved the session on is simply re-armed against the newer expiry.
260 func (m *Manager) relay(ex *exposure, pc *net.UDPConn, s *udpSession) { 273 func (m *Manager) relay(id string, ex *exposure, pc *net.UDPConn, s *udpSession) {
261 defer ex.dropSession(s) 274 defer func() {
275 ex.dropSession(s)
276 slog.Debug("exposure session closed", "exposure", id, "client", s.key)
277 }()
262 buf := make([]byte, udpDatagramMax) 278 buf := make([]byte, udpDatagramMax)
263 for { 279 for {
264 if err := s.guest.SetReadDeadline(s.expiry()); err != nil { 280 if err := s.guest.SetReadDeadline(s.expiry()); err != nil {
internal/agent/exposeproxy/udp_test.go
Old New
@@ -384,3 +384,56 @@ func mustUDPAddr(t *testing.T, addr string) *net.UDPAddr {
384 require.NoError(t, err) 384 require.NoError(t, err)
385 return ua 385 return ua
386 } 386 }
387
388 // TestUDPCountsItsSessionsAndRefusals is the counters' UDP half: sessions are
389 // what a published UDP port holds, and a full table turns callers away with
390 // nothing to show for it on the wire — no connection to refuse, no error to
391 // return, just a datagram that goes nowhere.
392 func TestUDPCountsItsSessionsAndRefusals(t *testing.T) {
393 g := newFakeUDPGuest(t)
394 m := newTestManager(t, map[string]string{"vm1": g.addr})
395 m.maxSessions = 1
396 spec := []*pb.ExposureDesired{desiredUDP("e1", "vm1", g.port, 0)}
397 m.Converge(spec)
398 addr := boundUDPPort(t, m, "e1")
399
400 held := udpClient(t, addr)
401 require.Equal(t, "echo:mine", say(t, held, "mine"))
402 waitSessions(t, m, "e1", 1)
403
404 got := counters(t, m, spec, "e1")
405 assert.Equal(t, int64(1), got.GetActive(), "a live conversation is one active session")
406 assert.Zero(t, got.GetRefused())
407
408 over := udpClient(t, addr)
409 _, err := over.Write([]byte("me too"))
410 require.NoError(t, err)
411 require.NoError(t, over.SetReadDeadline(time.Now().Add(300*time.Millisecond)))
412 _, err = over.Read(make([]byte, 64))
413 require.Error(t, err, "precondition: the table is full and the datagram went nowhere")
414
415 got = counters(t, m, spec, "e1")
416 assert.Equal(t, int64(1), got.GetRefused(), "the dropped datagram is the only trace that caller left")
417 assert.Equal(t, int64(1), got.GetActive(), "and the conversation that was working is untouched")
418 }
419
420 // TestUDPCountsADatagramForAGuestWithNoAddress: a guest still leasing is not a
421 // port at its cap, and the two must not read the same in a report.
422 func TestUDPCountsADatagramForAGuestWithNoAddress(t *testing.T) {
423 m := newTestManager(t, map[string]string{}) // vm1 has no address yet
424 spec := []*pb.ExposureDesired{desiredUDP("e1", "vm1", 9999, 0)}
425 m.Converge(spec)
426 addr := boundUDPPort(t, m, "e1")
427
428 c := udpClient(t, addr)
429 _, err := c.Write([]byte("anyone there"))
430 require.NoError(t, err)
431 require.NoError(t, c.SetReadDeadline(time.Now().Add(300*time.Millisecond)))
432 _, err = c.Read(make([]byte, 64))
433 require.Error(t, err)
434
435 got := counters(t, m, spec, "e1")
436 assert.Equal(t, int64(1), got.GetDropped())
437 assert.Zero(t, got.GetRefused())
438 assert.Zero(t, got.GetActive(), "a dropped datagram starts no session")
439 }
internal/agent/imagecache/imagecache.go
Old New
@@ -61,6 +61,11 @@ type Cache struct {
61 // cache over cap; use block-based accounting if that ever bites. 61 // cache over cap; use block-based accounting if that ever bites.
62 MaxBytes int64 62 MaxBytes int64
63 63
64 // progressStep is the package constant, held per-Cache so a test can watch
65 // a download advance over a handful of kilobytes instead of gigabytes.
66 // Written once at construction and only read after.
67 progressStep int64
68
64 // fetching collapses concurrent Ensure calls for the same image into one 69 // fetching collapses concurrent Ensure calls for the same image into one
65 // download+decode. Per-VM reconcile workers made creates concurrent, so a 70 // download+decode. Per-VM reconcile workers made creates concurrent, so a
66 // fleet rolling out one image now fetches it from every VM's worker at once; 71 // fleet rolling out one image now fetches it from every VM's worker at once;
@@ -73,12 +78,47 @@ type Cache struct {
73 // so large images on slow links still complete, but a hung connection cannot 78 // so large images on slow links still complete, but a hung connection cannot
74 // stall a reconcile worker forever. 79 // stall a reconcile worker forever.
75 func New(dir string) *Cache { 80 func New(dir string) *Cache {
76 return &Cache{dir: dir, http: &http.Client{Timeout: 10 * time.Minute}} 81 return &Cache{dir: dir, http: &http.Client{Timeout: 10 * time.Minute}, progressStep: defaultProgressStep}
82 }
83
84 // defaultProgressStep is how much has to move before a download says so again.
85 // A callback per Read would fire thousands of times for one image; every 32 MiB
86 // is often enough that a watcher sees the number climb and rare enough that
87 // what it feeds — a report row published under a lock — costs nothing.
88 const defaultProgressStep = 32 << 20
89
90 // progressWriter counts bytes past it and calls report as they pass, at most
91 // once per step. It writes nothing: it rides the download's existing
92 // MultiWriter, beside the file and the hash, so nothing in the copy path has to
93 // know it is there.
94 //
95 // total is what the server said the body is, and it is <= 0 whenever the server
96 // declined to say — a chunked response has no length. Consumers render that as
97 // "how far", not "how far of what".
98 type progressWriter struct {
99 total int64
100 step int64
101 done int64
102 marked int64
103 report func(done, total int64)
104 }
105
106 func (w *progressWriter) Write(p []byte) (int, error) {
107 w.done += int64(len(p))
108 if w.done-w.marked >= w.step {
109 w.marked = w.done
110 w.report(w.done, w.total)
111 }
112 return len(p), nil
77 } 113 }
78 114
79 // fetch downloads url into a temp file in the cache dir and verifies its 115 // fetch downloads url into a temp file in the cache dir and verifies its
80 // sha256. Returns the temp path; the caller owns renaming or removing it. 116 // sha256. Returns the temp path; the caller owns renaming or removing it.
81 func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) { 117 //
118 // progress, when non-nil, is told how far the body has come as it comes. It is
119 // called from the copy, so it must not block: a slow callback is a slow
120 // download.
121 func (c *Cache) fetch(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) {
82 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 122 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
83 if err != nil { 123 if err != nil {
84 return "", err 124 return "", err
@@ -96,7 +136,11 @@ func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) {
96 return "", err 136 return "", err
97 } 137 }
98 h := sha256.New() 138 h := sha256.New()
99 if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { 139 sink := []io.Writer{tmp, h}
140 if progress != nil {
141 sink = append(sink, &progressWriter{total: resp.ContentLength, step: c.progressStep, report: progress})
142 }
143 if _, err := io.Copy(io.MultiWriter(sink...), resp.Body); err != nil {
100 tmp.Close() 144 tmp.Close()
101 os.Remove(tmp.Name()) 145 os.Remove(tmp.Name())
102 return "", err 146 return "", err
@@ -124,14 +168,21 @@ func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) {
124 // bounded by the FIRST caller's context, so if that caller's pass times out the 168 // bounded by the FIRST caller's context, so if that caller's pass times out the
125 // waiters inherit its error; and a waiter whose own context expires first stops 169 // waiters inherit its error; and a waiter whose own context expires first stops
126 // waiting without cancelling the fetch, which continues for the others. 170 // waiting without cancelling the fetch, which continues for the others.
127 func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { 171 //
172 // progress, when non-nil, is told how far the download has come. It belongs to
173 // the caller that DOES the work: a caller collapsed onto another's fetch is not
174 // the one reading the body, so its callback never fires and it sees only that
175 // an image is being fetched. That is the honest reading — the bytes are not
176 // its download — and it is why the caller's own wording has to stand on its
177 // own without any numbers in it.
178 func (c *Cache) Ensure(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) {
128 // Guard path traversal: sha becomes part of the cache file path. Checked 179 // Guard path traversal: sha becomes part of the cache file path. Checked
129 // before the singleflight so a bad digest can never key an entry. 180 // before the singleflight so a bad digest can never key an entry.
130 if !sha256Re.MatchString(sha) { 181 if !sha256Re.MatchString(sha) {
131 return "", fmt.Errorf("invalid sha256: %q", sha) 182 return "", fmt.Errorf("invalid sha256: %q", sha)
132 } 183 }
133 184
134 ch := c.fetching.DoChan(sha, func() (any, error) { return c.ensureOnce(ctx, url, sha) }) 185 ch := c.fetching.DoChan(sha, func() (any, error) { return c.ensureOnce(ctx, url, sha, progress) })
135 select { 186 select {
136 case r := <-ch: 187 case r := <-ch:
137 if r.Err != nil { 188 if r.Err != nil {
@@ -145,7 +196,7 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
145 196
146 // ensureOnce is the un-deduplicated body of Ensure: at most one runs per sha at 197 // ensureOnce is the un-deduplicated body of Ensure: at most one runs per sha at
147 // a time. 198 // a time.
148 func (c *Cache) ensureOnce(ctx context.Context, url, sha string) (string, error) { 199 func (c *Cache) ensureOnce(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) {
149 final := filepath.Join(c.dir, sha+".raw") 200 final := filepath.Join(c.dir, sha+".raw")
150 if _, err := os.Stat(final); err == nil { 201 if _, err := os.Stat(final); err == nil {
151 // Hit: refresh recency so frequently-used images sort as recent. 202 // Hit: refresh recency so frequently-used images sort as recent.
@@ -154,7 +205,7 @@ func (c *Cache) ensureOnce(ctx context.Context, url, sha string) (string, error)
154 c.evict(final) 205 c.evict(final)
155 return final, nil 206 return final, nil
156 } 207 }
157 tmp, err := c.fetch(ctx, url, sha) 208 tmp, err := c.fetch(ctx, url, sha, progress)
158 if err != nil { 209 if err != nil {
159 return "", err 210 return "", err
160 } 211 }
internal/agent/imagecache/imagecache_test.go
Old New
@@ -10,6 +10,7 @@ import (
10 "net/http/httptest" 10 "net/http/httptest"
11 "os" 11 "os"
12 "path/filepath" 12 "path/filepath"
13 "strconv"
13 "sync/atomic" 14 "sync/atomic"
14 "testing" 15 "testing"
15 "time" 16 "time"
@@ -65,11 +66,11 @@ func TestEnsureDownloadsVerifiesAndCaches(t *testing.T) {
65 sha := hex.EncodeToString(sum[:]) 66 sha := hex.EncodeToString(sum[:])
66 c := New(t.TempDir()) 67 c := New(t.TempDir())
67 68
68 p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha) 69 p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha, nil)
69 require.NoError(t, err) 70 require.NoError(t, err)
70 assert.FileExists(t, p1) 71 assert.FileExists(t, p1)
71 72
72 p2, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha) 73 p2, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha, nil)
73 require.NoError(t, err) 74 require.NoError(t, err)
74 assert.Equal(t, p1, p2) 75 assert.Equal(t, p1, p2)
75 assert.Equal(t, int64(1), downloads.Load(), "second Ensure must hit the cache, not re-fetch") 76 assert.Equal(t, int64(1), downloads.Load(), "second Ensure must hit the cache, not re-fetch")
@@ -83,7 +84,7 @@ func TestEnsureMaterializesRawByRename(t *testing.T) {
83 ts, sum := serve(t, body) 84 ts, sum := serve(t, body)
84 c := New(t.TempDir()) 85 c := New(t.TempDir())
85 86
86 p, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum) 87 p, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, nil)
87 require.NoError(t, err) 88 require.NoError(t, err)
88 89
89 got, err := os.ReadFile(p) 90 got, err := os.ReadFile(p)
@@ -99,7 +100,7 @@ func TestEnsureDecodesQcow2Natively(t *testing.T) {
99 ts, sum := serve(t, tinyQcow2(t)) 100 ts, sum := serve(t, tinyQcow2(t))
100 c := New(t.TempDir()) 101 c := New(t.TempDir())
101 102
102 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 103 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
103 require.NoError(t, err) 104 require.NoError(t, err)
104 105
105 got, err := os.ReadFile(p) 106 got, err := os.ReadFile(p)
@@ -120,7 +121,7 @@ func TestEnsureRejectsUnparsableImagePermanently(t *testing.T) {
120 dir := t.TempDir() 121 dir := t.TempDir()
121 c := New(dir) 122 c := New(dir)
122 123
123 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 124 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
124 require.Error(t, err) 125 require.Error(t, err)
125 var perm interface{ Permanent() bool } 126 var perm interface{ Permanent() bool }
126 require.ErrorAs(t, err, &perm, "a broken container header must be marked permanent") 127 require.ErrorAs(t, err, &perm, "a broken container header must be marked permanent")
@@ -134,7 +135,7 @@ func TestEnsureRejectsUnparsableImagePermanently(t *testing.T) {
134 func TestEnsureRejectsChecksumMismatch(t *testing.T) { 135 func TestEnsureRejectsChecksumMismatch(t *testing.T) {
135 ts, _ := serve(t, []byte("evil-bytes")) 136 ts, _ := serve(t, []byte("evil-bytes"))
136 c := New(t.TempDir()) 137 c := New(t.TempDir())
137 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000") 138 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000", nil)
138 assert.Error(t, err, "tampered image must be rejected before conversion") 139 assert.Error(t, err, "tampered image must be rejected before conversion")
139 } 140 }
140 141
@@ -144,19 +145,19 @@ func TestEnsureRejectsInvalidSha(t *testing.T) {
144 c := New(t.TempDir()) 145 c := New(t.TempDir())
145 146
146 t.Run("path traversal", func(t *testing.T) { 147 t.Run("path traversal", func(t *testing.T) {
147 _, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd") 148 _, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd", nil)
148 assert.Error(t, err, "path traversal sha must be rejected") 149 assert.Error(t, err, "path traversal sha must be rejected")
149 assert.Contains(t, err.Error(), "invalid sha256") 150 assert.Contains(t, err.Error(), "invalid sha256")
150 }) 151 })
151 152
152 t.Run("uppercase hex", func(t *testing.T) { 153 t.Run("uppercase hex", func(t *testing.T) {
153 _, err := c.Ensure(context.Background(), "http://unused", 154 _, err := c.Ensure(context.Background(), "http://unused",
154 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") 155 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", nil)
155 assert.Error(t, err, "uppercase sha must be rejected") 156 assert.Error(t, err, "uppercase sha must be rejected")
156 }) 157 })
157 158
158 t.Run("too short", func(t *testing.T) { 159 t.Run("too short", func(t *testing.T) {
159 _, err := c.Ensure(context.Background(), "http://unused", "abc123") 160 _, err := c.Ensure(context.Background(), "http://unused", "abc123", nil)
160 assert.Error(t, err) 161 assert.Error(t, err)
161 }) 162 })
162 } 163 }
@@ -176,7 +177,7 @@ func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) {
176 require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644)) 177 require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644))
177 178
178 c := New(dir) 179 c := New(dir)
179 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 180 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
180 require.NoError(t, err) 181 require.NoError(t, err)
181 182
182 // The result must contain the decoded image, not the corrupt partial. 183 // The result must contain the decoded image, not the corrupt partial.
@@ -191,7 +192,7 @@ func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
191 dir := t.TempDir() 192 dir := t.TempDir()
192 c := New(dir) 193 c := New(dir)
193 194
194 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 195 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
195 require.NoError(t, err) 196 require.NoError(t, err)
196 197
197 entries, err := os.ReadDir(dir) 198 entries, err := os.ReadDir(dir)
@@ -204,7 +205,7 @@ func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
204 func ensureBytes(t *testing.T, c *Cache, body []byte) string { 205 func ensureBytes(t *testing.T, c *Cache, body []byte) string {
205 t.Helper() 206 t.Helper()
206 ts, sha := serve(t, body) 207 ts, sha := serve(t, body)
207 p, err := c.Ensure(context.Background(), ts.URL+"/img", sha) 208 p, err := c.Ensure(context.Background(), ts.URL+"/img", sha, nil)
208 require.NoError(t, err) 209 require.NoError(t, err)
209 return p 210 return p
210 } 211 }
@@ -325,7 +326,7 @@ func TestConcurrentEnsureFetchesOnce(t *testing.T) {
325 errs := make(chan error, callers) 326 errs := make(chan error, callers)
326 for range callers { 327 for range callers {
327 go func() { 328 go func() {
328 p, err := c.Ensure(context.Background(), srv.URL, sha) 329 p, err := c.Ensure(context.Background(), srv.URL, sha, nil)
329 if err != nil { 330 if err != nil {
330 errs <- err 331 errs <- err
331 return 332 return
@@ -389,7 +390,7 @@ func TestEnsureDecompressesGzippedRaw(t *testing.T) {
389 dir := t.TempDir() 390 dir := t.TempDir()
390 c := New(dir) 391 c := New(dir)
391 392
392 p, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum) 393 p, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum, nil)
393 require.NoError(t, err) 394 require.NoError(t, err)
394 395
395 got, err := os.ReadFile(p) 396 got, err := os.ReadFile(p)
@@ -412,7 +413,7 @@ func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) {
412 ts, sum := serve(t, []byte{0x1f, 0x8b, 'g', 'a', 'r', 'b', 'a', 'g', 'e', '!'}) 413 ts, sum := serve(t, []byte{0x1f, 0x8b, 'g', 'a', 'r', 'b', 'a', 'g', 'e', '!'})
413 c := New(t.TempDir()) 414 c := New(t.TempDir())
414 415
415 _, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum) 416 _, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum, nil)
416 require.Error(t, err) 417 require.Error(t, err)
417 var p interface{ Permanent() bool } 418 var p interface{ Permanent() bool }
418 require.ErrorAs(t, err, &p, "a broken gzip header must be marked permanent") 419 require.ErrorAs(t, err, &p, "a broken gzip header must be marked permanent")
@@ -429,7 +430,7 @@ func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) {
429 dir := t.TempDir() 430 dir := t.TempDir()
430 c := New(dir) 431 c := New(dir)
431 432
432 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2.gz", sum) 433 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2.gz", sum, nil)
433 require.Error(t, err) 434 require.Error(t, err)
434 var p interface{ Permanent() bool } 435 var p interface{ Permanent() bool }
435 require.ErrorAs(t, err, &p, "a gzipped qcow2 can never decompress to a raw image") 436 require.ErrorAs(t, err, &p, "a gzipped qcow2 can never decompress to a raw image")
@@ -440,3 +441,86 @@ func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) {
440 require.NoError(t, err) 441 require.NoError(t, err)
441 assert.Empty(t, entries, "a rejected image must leave nothing in the cache") 442 assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
442 } 443 }
444
445 // TestEnsureReportsDownloadProgress is the narration leg: a multi-gigabyte
446 // image is minutes of silence unless the download says how far it has come, and
447 // the length the server declared is what turns "how far" into "how far of what".
448 func TestEnsureReportsDownloadProgress(t *testing.T) {
449 body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 4096) // 16 KiB of raw
450 // Declares its length, the way a server handing out an image file does.
451 // httptest's default is chunked, which is the other case entirely.
452 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
453 w.Header().Set("Content-Length", strconv.Itoa(len(body)))
454 w.Write(body)
455 }))
456 t.Cleanup(ts.Close)
457 digest := sha256.Sum256(body)
458 sum := hex.EncodeToString(digest[:])
459 c := New(t.TempDir())
460 c.progressStep = 4 << 10
461
462 type step struct{ done, total int64 }
463 var steps []step
464 _, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(done, total int64) {
465 steps = append(steps, step{done, total})
466 })
467 require.NoError(t, err)
468
469 require.NotEmpty(t, steps, "a download must say how far it has come")
470 for _, s := range steps {
471 assert.Equal(t, int64(len(body)), s.total, "the declared length rides every report")
472 assert.LessOrEqual(t, s.done, int64(len(body)))
473 }
474 for i := 1; i < len(steps); i++ {
475 assert.Greater(t, steps[i].done, steps[i-1].done, "progress only moves forward")
476 }
477 }
478
479 // TestEnsureProgressWithoutDeclaredLength: a server that will not say how long
480 // the body is (chunked) still gets counted, with a total of -1 saying so. The
481 // renderer downstream needs that difference to be legible, not guessed at.
482 func TestEnsureProgressWithoutDeclaredLength(t *testing.T) {
483 body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 4096)
484 ts, sum := serve(t, body) // httptest without a Content-Length: chunked
485 c := New(t.TempDir())
486 c.progressStep = 4 << 10
487
488 var totals []int64
489 _, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(_, total int64) {
490 totals = append(totals, total)
491 })
492 require.NoError(t, err)
493 require.NotEmpty(t, totals)
494 for _, total := range totals {
495 assert.Negative(t, total, "an undeclared length is reported as unknown, not as zero")
496 }
497 }
498
499 // TestProgressThrottlesToItsStep pins the throttle itself: the callback feeds a
500 // published report row, so a download must not call it once per read.
501 func TestProgressThrottlesToItsStep(t *testing.T) {
502 var calls int
503 w := &progressWriter{total: 400, step: 100, report: func(int64, int64) { calls++ }}
504 for range 40 {
505 _, err := w.Write(make([]byte, 10))
506 require.NoError(t, err)
507 }
508 assert.Equal(t, 4, calls, "40 writes of 10 bytes past a 100-byte step is 4 reports, not 40")
509 }
510
511 // TestEnsureWithoutProgressStaysQuiet: the callback is optional, and a cache hit
512 // downloads nothing to report on.
513 func TestEnsureCacheHitReportsNothing(t *testing.T) {
514 body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 1024)
515 ts, sum := serve(t, body)
516 c := New(t.TempDir())
517 c.progressStep = 1
518
519 _, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, nil)
520 require.NoError(t, err)
521
522 called := false
523 _, err = c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(int64, int64) { called = true })
524 require.NoError(t, err)
525 assert.False(t, called, "a cache hit fetches nothing, so it reports nothing")
526 }
internal/agent/reconcile/narrate_test.go
Old New
@@ -0,0 +1,90 @@
1 package reconcile
2
3 import (
4 "context"
5 "testing"
6
7 "github.com/a73x/eitri/internal/agent/state"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // narrateCreate runs one VM's create with a publisher attached and returns
13 // every sentence it published, in order. This is the seam the worker uses in
14 // production (see worker.publish) — a pass that has not finished still has
15 // something to say.
16 func narrateCreate(t *testing.T, f *fixture, id string) []string {
17 t.Helper()
18 var said []string
19 var res vmResult
20 f.eng.create(context.Background(), vm(id), state.Record{}, false, &res,
21 func(interim vmResult) {
22 require.Equal(t, "creating", interim.vm.GetPhase(), "narration never changes the phase")
23 said = append(said, interim.vm.GetStatusDetail())
24 })
25 return said
26 }
27
28 // TestACreateSaysWhatItIsDoing is the whole point of the field: `creating` is
29 // one word for image, disk, seed and boot, and only the host can see which of
30 // them a VM is sitting in.
31 func TestACreateSaysWhatItIsDoing(t *testing.T) {
32 f := setup(t)
33 said := narrateCreate(t, f, "vm1")
34 assert.Equal(t, []string{
35 "waiting for a create slot on this host",
36 "downloading image",
37 "preparing root disk",
38 "building the cloud-init seed",
39 "booting",
40 }, said)
41 }
42
43 // TestACreateCountsOutTheDownload pins the one step that takes long enough to
44 // need a number rather than a name.
45 func TestACreateCountsOutTheDownload(t *testing.T) {
46 f := setup(t)
47 f.eng.Images = func(_ context.Context, _, sha string, progress func(done, total int64)) (string, error) {
48 progress(1_288_490_188, 3_972_844_748) // 1.2 of 3.7 GiB
49 return "/cache/" + sha + ".raw", nil
50 }
51 assert.Contains(t, narrateCreate(t, f, "vm1"), "downloading image 1.2/3.7 GiB")
52 }
53
54 // TestNarrationCarriesTheGuestHostKey: the public host key is level-triggered —
55 // it must be in EVERY report for as long as the VM exists, or the control plane
56 // stops re-certifying it. A row published mid-pass is a report row like any
57 // other and cannot be the one that drops it.
58 func TestNarrationCarriesTheGuestHostKey(t *testing.T) {
59 f := setup(t)
60 var res vmResult
61 res.hostPubKey = "ssh-ed25519 AAAA-test guest"
62 published := 0
63 f.eng.create(context.Background(), vm("vm1"), state.Record{}, false, &res,
64 func(interim vmResult) {
65 published++
66 assert.Equal(t, res.hostPubKey, interim.hostPubKey)
67 })
68 assert.Positive(t, published)
69 }
70
71 // TestACreateWithNobodyListeningStillCreates: narration is commentary, and a
72 // pass with no publisher must behave exactly as it always did.
73 func TestACreateWithNobodyListeningStillCreates(t *testing.T) {
74 f := setup(t)
75 var res vmResult
76 f.eng.create(context.Background(), vm("vm1"), state.Record{}, false, &res, nil)
77 require.NotNil(t, res.vm)
78 assert.Equal(t, "ready", res.vm.GetPhase())
79 assert.Empty(t, res.vm.GetStatusDetail(), "a settled VM has nothing to add")
80 }
81
82 func TestDownloadDetailWords(t *testing.T) {
83 assert.Equal(t, "downloading image 1.2/3.7 GiB", downloadDetail(1_288_490_188, 3_972_844_748))
84 // Both halves scale together off the larger, so the pair reads as a fraction
85 // rather than as arithmetic between two units.
86 assert.Equal(t, "downloading image 0.4/3.7 GiB", downloadDetail(429_496_730, 3_972_844_748))
87 assert.Equal(t, "downloading image 120.0/512.0 MiB", downloadDetail(125_829_120, 536_870_912))
88 // A server that declared no length gets the half it can vouch for.
89 assert.Equal(t, "downloading image 1.2 GiB", downloadDetail(1_288_490_188, -1))
90 }
internal/agent/reconcile/reconcile.go
Old New
@@ -118,7 +118,13 @@ type Engine struct {
118 118
119 // Images resolves an image URL+sha256 to a local base-image path, fetching 119 // Images resolves an image URL+sha256 to a local base-image path, fetching
120 // if necessary. Returns the path to the raw base image. 120 // if necessary. Returns the path to the raw base image.
121 Images func(ctx context.Context, url, sha string) (string, error) 121 //
122 // progress is called as the download advances, and is the one thing on this
123 // seam that exists for the operator rather than for the VM: an image is
124 // gigabytes and a create is otherwise one word for the whole time it takes.
125 // An implementation that cannot say (a cache hit, a fetch collapsed onto
126 // another VM's) simply never calls it.
127 Images func(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error)
122 128
123 // Seed builds the cloud-init NoCloud seed ISO at outPath. 129 // Seed builds the cloud-init NoCloud seed ISO at outPath.
124 Seed func(outPath string, p seed.Params) error 130 Seed func(outPath string, p seed.Params) error
@@ -349,6 +355,17 @@ func (e *Engine) fenceReport(currentEpoch uint64) *pb.ActualStateReport {
349 return rep 355 return rep
350 } 356 }
351 357
358 // publisher hands a VM's row upward mid-pass, before the pass that owns it has
359 // finished. A pass publishes its result when it ENDS, and a first create ends
360 // minutes after it starts — image, disk, seed, boot — so the slowest and most
361 // opaque part of a VM's life was the part the host report carried no row for at
362 // all, and everything above it could say was "creating".
363 //
364 // Nil is a caller with nothing collecting rows (the fence path, a test calling
365 // a pass directly), and narration is then skipped rather than being an error:
366 // what it produces is commentary, and no decision anywhere rests on it.
367 type publisher func(vmResult)
368
352 // reconcileOne is ONE VM's complete reconcile pass: bounded by VMTimeout, it 369 // reconcileOne is ONE VM's complete reconcile pass: bounded by VMTimeout, it
353 // loads that VM's own record and either drives it toward desired or reaps it. 370 // loads that VM's own record and either drives it toward desired or reaps it.
354 // It is the entire unit of work a per-VM worker runs, and it touches no other 371 // It is the entire unit of work a per-VM worker runs, and it touches no other
@@ -364,7 +381,7 @@ func (e *Engine) fenceReport(currentEpoch uint64) *pb.ActualStateReport {
364 // store recovers, and the VM keeps its last-known row in the report meanwhile 381 // store recovers, and the VM keeps its last-known row in the report meanwhile
365 // (see worker.run). Acting on the unreadable record instead would route a 382 // (see worker.run). Acting on the unreadable record instead would route a
366 // running VM into create(). 383 // running VM into create().
367 func (e *Engine) reconcileOne(ctx context.Context, id string, a assignment) (vmResult, bool) { 384 func (e *Engine) reconcileOne(ctx context.Context, id string, a assignment, pub publisher) (vmResult, bool) {
368 if e.VMTimeout > 0 { 385 if e.VMTimeout > 0 {
369 var cancel context.CancelFunc 386 var cancel context.CancelFunc
370 ctx, cancel = context.WithTimeout(ctx, e.VMTimeout) 387 ctx, cancel = context.WithTimeout(ctx, e.VMTimeout)
@@ -398,7 +415,7 @@ func (e *Engine) reconcileOne(ctx context.Context, id string, a assignment) (vmR
398 rec.QuarantineTombstoned = false 415 rec.QuarantineTombstoned = false
399 _ = e.St.SaveVM(rec) 416 _ = e.St.SaveVM(rec)
400 } 417 }
401 e.reconcileVM(ctx, a.desired, rec, ok, &res) 418 e.reconcileVM(ctx, a.desired, rec, ok, &res, pub)
402 return res, true 419 return res, true
403 } 420 }
404 421
@@ -493,9 +510,9 @@ func (e *Engine) reapVM(ctx context.Context, id string, rec state.Record, isTomb
493 // PrepareRootDisk but before boot leaves a disk on a still-empty BootID. Treating 510 // PrepareRootDisk but before boot leaves a disk on a still-empty BootID. Treating
494 // that disk as "exists" would divert the retry to converge(), which never 511 // that disk as "exists" would divert the retry to converge(), which never
495 // rebuilds the disk or seed, and the VM would never recover. 512 // rebuilds the disk or seed, and the VM would never recover.
496 func (e *Engine) reconcileVM(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult) { 513 func (e *Engine) reconcileVM(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult, pub publisher) {
497 if !ok || rec.BootID == "" { 514 if !ok || rec.BootID == "" {
498 e.create(ctx, d, rec, ok, res) 515 e.create(ctx, d, rec, ok, res, pub)
499 return 516 return
500 } 517 }
501 e.converge(ctx, d, rec, res) 518 e.converge(ctx, d, rec, res)
@@ -635,7 +652,7 @@ func (e *Engine) quotaCheckLocked(vmID string, spec state.VMSpec) string {
635 // prior record (retry budget, last known address) and ok reports whether one 652 // prior record (retry budget, last known address) and ok reports whether one
636 // exists. Quota comes from the serialized admission ledger (see admit); the 653 // exists. Quota comes from the serialized admission ledger (see admit); the
637 // address comes from the backend, at boot. 654 // address comes from the backend, at boot.
638 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult) { 655 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult, pub publisher) {
639 // Defensive: never start an attempt (which would burn retry budget) on a 656 // Defensive: never start an attempt (which would burn retry budget) on a
640 // context that is already dead. UNREACHABLE today — a pass context is a 657 // context that is already dead. UNREACHABLE today — a pass context is a
641 // fresh context.Background plus VMTimeout (see worker.run), so it cannot 658 // fresh context.Background plus VMTimeout (see worker.run), so it cannot
@@ -716,6 +733,23 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
716 return 733 return
717 } 734 }
718 735
736 // say publishes what this create is doing right now, on the row this VM
737 // would report anyway: still creating, still stopped, no error. It carries
738 // rec's address and this VM's public host key because BOTH are
739 // level-triggered — a row that dropped the key would ask the control plane
740 // to forget a guest's certificate for a tick — and rec is read at each call,
741 // so a step after Boot narrates with the address Boot found.
742 say := func(detail string) {
743 if pub == nil {
744 return
745 }
746 var interim vmResult
747 interim.hostPubKey = res.hostPubKey
748 interim.report(d.VmId, rec.IP, "stopped", "creating", "")
749 interim.vm.StatusDetail = detail
750 pub(interim)
751 }
752
719 // Ask the backend whether this host can run a guest before spending anything 753 // Ask the backend whether this host can run a guest before spending anything
720 // on finding out. Ahead of the throttle as well as the fetch: a create that 754 // on finding out. Ahead of the throttle as well as the fetch: a create that
721 // can only be refused should not queue for a slot that a create which might 755 // can only be refused should not queue for a slot that a create which might
@@ -735,6 +769,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
735 // A wait that outlives VMTimeout fails the attempt, and failCreate REFUNDS a 769 // A wait that outlives VMTimeout fails the attempt, and failCreate REFUNDS a
736 // ctx-expiry failure, so a queued VM never burns retry budget for waiting. 770 // ctx-expiry failure, so a queued VM never burns retry budget for waiting.
737 // Held through Boot, which is cheap but immediately I/O-heavy in the guest. 771 // Held through Boot, which is cheap but immediately I/O-heavy in the guest.
772 say("waiting for a create slot on this host")
738 release, err := e.acquireCreateSlot(ctx) 773 release, err := e.acquireCreateSlot(ctx)
739 if err != nil { 774 if err != nil {
740 e.failCreate(ctx, rec, err, res) 775 e.failCreate(ctx, rec, err, res)
@@ -742,14 +777,21 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
742 } 777 }
743 defer release() 778 defer release()
744 779
745 // Resolve base image. 780 // Resolve base image. The download is the longest thing a create does and
746 basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256) 781 // the one an operator most often wants a number for, so it narrates itself
782 // as it goes; a cached image passes straight through and the line stands for
783 // only as long as the check takes.
784 say("downloading image")
785 basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256, func(done, total int64) {
786 say(downloadDetail(done, total))
787 })
747 if err != nil { 788 if err != nil {
748 e.failCreate(ctx, rec, err, res) 789 e.failCreate(ctx, rec, err, res)
749 return 790 return
750 } 791 }
751 792
752 // Prepare root disk. 793 // Prepare root disk.
794 say("preparing root disk")
753 if err := e.Prov.PrepareRootDisk(ctx, rec.Spec, basePath); err != nil { 795 if err := e.Prov.PrepareRootDisk(ctx, rec.Spec, basePath); err != nil {
754 e.failCreate(ctx, rec, err, res) 796 e.failCreate(ctx, rec, err, res)
755 return 797 return
@@ -757,6 +799,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
757 799
758 // Build cloud-init seed ISO. The guest gets its address from the host 800 // Build cloud-init seed ISO. The guest gets its address from the host
759 // network, so no IP or gateway is baked into the seed. 801 // network, so no IP or gateway is baked into the seed.
802 say("building the cloud-init seed")
760 if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{ 803 if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{
761 Hostname: d.Name, 804 Hostname: d.Name,
762 InstanceID: d.VmId, 805 InstanceID: d.VmId,
@@ -778,6 +821,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
778 // report row carrying the address, as it always has — and one that learns 821 // report row carrying the address, as it always has — and one that learns
779 // it later fills it in on a converge poll. 822 // it later fills it in on a converge poll.
780 if d.PowerState == "running" { 823 if d.PowerState == "running" {
824 say("booting")
781 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { 825 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
782 e.failCreate(ctx, rec, err, res) 826 e.failCreate(ctx, rec, err, res)
783 return 827 return
@@ -1002,6 +1046,31 @@ func (r *vmResult) merge(rep *pb.ActualStateReport) {
1002 } 1046 }
1003 } 1047 }
1004 1048
1049 // downloadDetail words how far an image download has come.
1050 //
1051 // Both numbers are scaled by the SAME unit, chosen from the larger of them, so
1052 // the pair can be read against each other at a glance — "0.4/3.7 GiB" is a
1053 // fraction; "419.4 MiB/3.7 GiB" is arithmetic. A server that declared no length
1054 // (chunked, total <= 0) gets the only half it can vouch for: how far, with
1055 // nothing said about how far there is to go.
1056 func downloadDetail(done, total int64) string {
1057 scale, unit := byteScale(max(done, total))
1058 if total <= 0 {
1059 return fmt.Sprintf("downloading image %.1f %s", float64(done)/scale, unit)
1060 }
1061 return fmt.Sprintf("downloading image %.1f/%.1f %s", float64(done)/scale, float64(total)/scale, unit)
1062 }
1063
1064 // byteScale picks the unit a size reads best in: the largest one it is at least
1065 // one of, floored at MiB because an image smaller than that does not exist and
1066 // a download reported in kilobytes would look like a failure.
1067 func byteScale(n int64) (float64, string) {
1068 if n >= 1<<30 {
1069 return 1 << 30, "GiB"
1070 }
1071 return 1 << 20, "MiB"
1072 }
1073
1005 // newActualVM builds one ActualVM row from what a reconcile pass observed. 1074 // newActualVM builds one ActualVM row from what a reconcile pass observed.
1006 // The row's remaining field, ssh_host_pubkey, is stamped by merge — see 1075 // The row's remaining field, ssh_host_pubkey, is stamped by merge — see
1007 // vmResult.hostPubKey. Unset values are the proto zero-value "". 1076 // vmResult.hostPubKey. Unset values are the proto zero-value "".
internal/agent/reconcile/reconcile_test.go
Old New
@@ -199,7 +199,7 @@ func (f *fixture) newEngine() *Engine {
199 return &Engine{ 199 return &Engine{
200 St: f.st, 200 St: f.st,
201 Prov: f.prov, 201 Prov: f.prov,
202 Images: func(ctx context.Context, url, sha string) (string, error) { 202 Images: func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
203 return "/cache/" + sha + ".raw", nil 203 return "/cache/" + sha + ".raw", nil
204 }, 204 },
205 Seed: func(out string, p seed.Params) error { return nil }, 205 Seed: func(out string, p seed.Params) error { return nil },
@@ -615,7 +615,7 @@ func TestFenceReportIncludesQuarantinedVMs(t *testing.T) {
615 func TestVMTimeoutBoundsSlowOperations(t *testing.T) { 615 func TestVMTimeoutBoundsSlowOperations(t *testing.T) {
616 f := setup(t) 616 f := setup(t)
617 f.eng.VMTimeout = 50 * time.Millisecond 617 f.eng.VMTimeout = 50 * time.Millisecond
618 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 618 f.eng.Images = func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
619 <-ctx.Done() // wedged until the watchdog fires 619 <-ctx.Done() // wedged until the watchdog fires
620 return "", ctx.Err() 620 return "", ctx.Err()
621 } 621 }
@@ -639,7 +639,7 @@ func TestVMTimeoutBoundsSlowOperations(t *testing.T) {
639 func TestVMTimeoutZeroDisablesWatchdog(t *testing.T) { 639 func TestVMTimeoutZeroDisablesWatchdog(t *testing.T) {
640 f := setup(t) 640 f := setup(t)
641 sawDeadline := false 641 sawDeadline := false
642 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 642 f.eng.Images = func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
643 _, sawDeadline = ctx.Deadline() 643 _, sawDeadline = ctx.Deadline()
644 return "/cache/x.raw", nil 644 return "/cache/x.raw", nil
645 } 645 }
@@ -655,7 +655,7 @@ func TestVMTimeoutZeroDisablesWatchdog(t *testing.T) {
655 func TestWatchdogExpiryDoesNotBurnCreateAttempts(t *testing.T) { 655 func TestWatchdogExpiryDoesNotBurnCreateAttempts(t *testing.T) {
656 f := setup(t) 656 f := setup(t)
657 f.eng.VMTimeout = 50 * time.Millisecond 657 f.eng.VMTimeout = 50 * time.Millisecond
658 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 658 f.eng.Images = func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
659 <-ctx.Done() // every fetch wedges until the watchdog fires 659 <-ctx.Done() // every fetch wedges until the watchdog fires
660 return "", ctx.Err() 660 return "", ctx.Err()
661 } 661 }
@@ -707,7 +707,7 @@ func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) {
707 func TestPreflightRefusalFailsBeforeTheImageFetch(t *testing.T) { 707 func TestPreflightRefusalFailsBeforeTheImageFetch(t *testing.T) {
708 f := setup(t) 708 f := setup(t)
709 var fetches int 709 var fetches int
710 f.eng.Images = func(context.Context, string, string) (string, error) { 710 f.eng.Images = func(context.Context, string, string, func(int64, int64)) (string, error) {
711 fetches++ 711 fetches++
712 return "/cache/x.raw", nil 712 return "/cache/x.raw", nil
713 } 713 }
internal/agent/reconcile/worker.go
Old New
@@ -200,7 +200,7 @@ func (w *worker) run() {
200 200
201 // LOAD-BEARING: the pass runs with w.mu RELEASED. That is what lets 201 // LOAD-BEARING: the pass runs with w.mu RELEASED. That is what lets
202 // deliver and collect run at full speed while this VM does slow work. 202 // deliver and collect run at full speed while this VM does slow work.
203 res, ok := w.eng.reconcileOne(context.Background(), w.id, a) 203 res, ok := w.eng.reconcileOne(context.Background(), w.id, a, w.publish)
204 204
205 w.mu.Lock() 205 w.mu.Lock()
206 // Publish only a pass that ran. A skipped pass (this VM's record could 206 // Publish only a pass that ran. A skipped pass (this VM's record could
@@ -218,6 +218,22 @@ func (w *worker) run() {
218 } 218 }
219 } 219 }
220 220
221 // publish swaps in a result from INSIDE a running pass, so the host report can
222 // carry a row for work that has not finished. Every other publish happens at
223 // the end of a pass (see run); this is the one that happens during one, and it
224 // exists because the pass that most needs reporting on is the one that takes
225 // minutes — a first create.
226 //
227 // It takes the same lock the pass-end publish takes and holds it no longer, so
228 // a narrating create is no more able to delay a report than a silent one. The
229 // pass's own result lands on top when it ends, which is what keeps this
230 // commentary and not state: nothing here can outlive the pass that said it.
231 func (w *worker) publish(res vmResult) {
232 w.mu.Lock()
233 defer w.mu.Unlock()
234 w.result = res
235 }
236
221 func (w *worker) stop() { 237 func (w *worker) stop() {
222 w.mu.Lock() 238 w.mu.Lock()
223 w.stopped = true 239 w.stopped = true
internal/agent/reconcile/worker_test.go
Old New
@@ -13,8 +13,8 @@ import (
13 // wedge returns an Images func that blocks every fetch until release is closed, 13 // wedge returns an Images func that blocks every fetch until release is closed,
14 // and signals arrived once per call so a test can observe how many VMs are 14 // and signals arrived once per call so a test can observe how many VMs are
15 // inside a slow operation at the same moment. 15 // inside a slow operation at the same moment.
16 func wedge(arrived chan<- struct{}, release <-chan struct{}) func(context.Context, string, string) (string, error) { 16 func wedge(arrived chan<- struct{}, release <-chan struct{}) func(context.Context, string, string, func(int64, int64)) (string, error) {
17 return func(ctx context.Context, url, sha string) (string, error) { 17 return func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
18 select { 18 select {
19 case arrived <- struct{}{}: 19 case arrived <- struct{}{}:
20 default: 20 default:
@@ -65,9 +65,16 @@ func TestBusyVMDoesNotBlockTheReport(t *testing.T) {
65 t.Fatal("worker never started the create") 65 t.Fatal("worker never started the create")
66 } 66 }
67 67
68 // The next tick must come back promptly even though vm1 is still wedged. 68 // The next tick must come back promptly even though vm1 is still wedged —
69 // and it carries the wedged VM's row, saying what the wedge is. A create
70 // publishes as it goes now (see worker.publish), which is precisely the case
71 // this test wedges: before, a VM's slowest minutes were a report with no row
72 // for it at all.
69 rep := stepNoWait(t, f, snap(1, vm("vm1"))) 73 rep := stepNoWait(t, f, snap(1, vm("vm1")))
70 assert.Nil(t, findVM(rep, "vm1"), "a VM that has never published contributes no row") 74 row := findVM(rep, "vm1")
75 require.NotNil(t, row, "a wedged create must still be in the report")
76 assert.Equal(t, "creating", row.GetPhase())
77 assert.Equal(t, "downloading image", row.GetStatusDetail(), "the row says which step is wedged")
71 78
72 // Once the operation completes the VM publishes and appears in the report. 79 // Once the operation completes the VM publishes and appears in the report.
73 close(release) 80 close(release)
@@ -170,7 +177,7 @@ func TestCreateConcurrencyIsCapped(t *testing.T) {
170 177
171 entered := make(chan struct{}, 3) 178 entered := make(chan struct{}, 3)
172 release := make(chan struct{}) 179 release := make(chan struct{})
173 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 180 f.eng.Images = func(ctx context.Context, url, sha string, _ func(done, total int64)) (string, error) {
174 entered <- struct{}{} 181 entered <- struct{}{}
175 <-release 182 <-release
176 return "/cache/x.raw", nil 183 return "/cache/x.raw", nil
internal/agent/syncclient/client_test.go
Old New
@@ -217,7 +217,7 @@ func newClient(t *testing.T, addr, fp, hostID, cred string) *Client {
217 require.NoError(t, agentSt.SaveIdentity(id)) 217 require.NoError(t, agentSt.SaveIdentity(id))
218 engine := &reconcile.Engine{ 218 engine := &reconcile.Engine{
219 St: agentSt, Prov: noopProv{}, 219 St: agentSt, Prov: noopProv{},
220 Images: func(context.Context, string, string) (string, error) { return "/x.raw", nil }, 220 Images: func(context.Context, string, string, func(int64, int64)) (string, error) { return "/x.raw", nil },
221 Seed: func(string, seed.Params) error { return nil }, 221 Seed: func(string, seed.Params) error { return nil },
222 HostKey: state.LoadOrCreateHostKey, 222 HostKey: state.LoadOrCreateHostKey,
223 // CIDR/grace not exercised by these tests. 223 // CIDR/grace not exercised by these tests.
internal/mcpserver/tools.go
Old New
@@ -266,7 +266,7 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error
266 // tool's "do not assume failure" contract. Only real cancellation (ctx.Done) 266 // tool's "do not assume failure" contract. Only real cancellation (ctx.Done)
267 // aborts immediately. 267 // aborts immediately.
268 func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Time) (string, error) { 268 func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Time) (string, error) {
269 last := "" 269 last, detail := "", ""
270 sawListing := false 270 sawListing := false
271 var lastErr error 271 var lastErr error
272 for time.Now().Before(deadline) { 272 for time.Now().Before(deadline) {
@@ -279,13 +279,13 @@ func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Ti
279 if vm.ID != id { 279 if vm.ID != id {
280 continue 280 continue
281 } 281 }
282 last = vm.Lifecycle 282 last, detail = vm.Lifecycle, vm.StatusDetail
283 if vm.Lifecycle == "ready" && vm.AssignedIP != "" { 283 if vm.Lifecycle == "ready" && vm.AssignedIP != "" {
284 return vm.AssignedIP, nil 284 return vm.AssignedIP, nil
285 } 285 }
286 } 286 }
287 } 287 }
288 reportProgress(ctx, fmt.Sprintf("creating vm %s: %s", name, lifecycleOrUnknown(last))) 288 reportProgress(ctx, fmt.Sprintf("creating vm %s: %s", name, lifecycleOrUnknown(last, detail)))
289 select { 289 select {
290 case <-ctx.Done(): 290 case <-ctx.Done():
291 return "", ctx.Err() 291 return "", ctx.Err()
@@ -300,10 +300,18 @@ func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Ti
300 300
301 // lifecycleOrUnknown words a lifecycle for a progress message, covering the 301 // lifecycleOrUnknown words a lifecycle for a progress message, covering the
302 // first poll or two where the control plane has not listed the VM yet. 302 // first poll or two where the control plane has not listed the VM yet.
303 func lifecycleOrUnknown(lifecycle string) string { 303 //
304 // The host's own account of what it is doing rides along when there is one.
305 // "creating" is a single word for minutes of image, disk and boot, and a caller
306 // watching this stream has no other way to tell a download in progress from a
307 // create that is going nowhere.
308 func lifecycleOrUnknown(lifecycle, detail string) string {
304 if lifecycle == "" { 309 if lifecycle == "" {
305 return "not listed yet" 310 return "not listed yet"
306 } 311 }
312 if detail != "" {
313 return lifecycle + ", " + detail
314 }
307 return lifecycle 315 return lifecycle
308 } 316 }
309 317
internal/pb/sync.pb.go
Old New
@@ -610,6 +610,15 @@ type ActualVM struct {
610 // every report for as long as the VM exists (level-triggered), so a lost 610 // every report for as long as the VM exists (level-triggered), so a lost
611 // snapshot or a control-plane restart re-certifies without operator action. 611 // snapshot or a control-plane restart re-certifies without operator action.
612 SshHostPubkey string `protobuf:"bytes,6,opt,name=ssh_host_pubkey,json=sshHostPubkey,proto3" json:"ssh_host_pubkey,omitempty"` 612 SshHostPubkey string `protobuf:"bytes,6,opt,name=ssh_host_pubkey,json=sshHostPubkey,proto3" json:"ssh_host_pubkey,omitempty"`
613 // What this VM's host is doing about it right now, in the host's own words:
614 // "downloading image 1.2/3.7 GiB", "preparing root disk", "booting". A create
615 // is minutes of work behind one word, and the host is the only thing that can
616 // see inside it — the control plane knows the VM is creating and nothing more.
617 //
618 // Free text for display, never parsed. Empty is the normal state of a settled
619 // VM and means only "nothing to add": an agent that predates this field sends
620 // it never, and a console reading it reads exactly what it read before.
621 StatusDetail string `protobuf:"bytes,7,opt,name=status_detail,json=statusDetail,proto3" json:"status_detail,omitempty"`
613 unknownFields protoimpl.UnknownFields 622 unknownFields protoimpl.UnknownFields
614 sizeCache protoimpl.SizeCache 623 sizeCache protoimpl.SizeCache
615 } 624 }
@@ -686,6 +695,13 @@ func (x *ActualVM) GetSshHostPubkey() string {
686 return "" 695 return ""
687 } 696 }
688 697
698 func (x *ActualVM) GetStatusDetail() string {
699 if x != nil {
700 return x.StatusDetail
701 }
702 return ""
703 }
704
689 type QuarantinedVM struct { 705 type QuarantinedVM struct {
690 state protoimpl.MessageState `protogen:"open.v1"` 706 state protoimpl.MessageState `protogen:"open.v1"`
691 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` 707 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
@@ -1494,6 +1510,7 @@ type ExposureActual struct {
1494 Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` 1510 Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
1495 State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` // "active"|"failed" 1511 State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` // "active"|"failed"
1496 Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // the OS error, when failed; a bound port's ongoing trouble otherwise 1512 Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // the OS error, when failed; a bound port's ongoing trouble otherwise
1513 Sessions *ExposureSessions `protobuf:"bytes,4,opt,name=sessions,proto3" json:"sessions,omitempty"`
1497 unknownFields protoimpl.UnknownFields 1514 unknownFields protoimpl.UnknownFields
1498 sizeCache protoimpl.SizeCache 1515 sizeCache protoimpl.SizeCache
1499 } 1516 }
@@ -1549,6 +1566,95 @@ func (x *ExposureActual) GetReason() string {
1549 return "" 1566 return ""
1550 } 1567 }
1551 1568
1569 func (x *ExposureActual) GetSessions() *ExposureSessions {
1570 if x != nil {
1571 return x.Sessions
1572 }
1573 return nil
1574 }
1575
1576 // ExposureSessions is what one published port has carried since the agent
1577 // serving it started: what it holds this instant, and what it has turned away
1578 // or could not carry along the way.
1579 //
1580 // The two totals only ever go up, deliberately. Loss on a published port is
1581 // intermittent — a burst at the cap, a guest that went away for a second — and
1582 // a gauge sampled once per report would show a clean zero for every one of
1583 // those bursts, which reads as a port with nothing wrong. A running total
1584 // cannot hide a burst it has already counted.
1585 //
1586 // A MESSAGE rather than three scalars beside the state, so that its absence is
1587 // readable. An agent that predates these counters reports none, which is not
1588 // the same fact as an exposure that has counted zero; the console says nothing
1589 // rather than presenting a zero it was never told.
1590 type ExposureSessions struct {
1591 state protoimpl.MessageState `protogen:"open.v1"`
1592 // active is what the port holds right now — spliced TCP connections, or live
1593 // UDP client sessions. The one gauge here, and the only number that goes down.
1594 Active int64 `protobuf:"varint,1,opt,name=active,proto3" json:"active,omitempty"`
1595 // refused counts callers turned away because the port was already at its cap:
1596 // a TCP connection accepted and closed, a UDP datagram dropped without
1597 // starting a session.
1598 Refused int64 `protobuf:"varint,2,opt,name=refused,proto3" json:"refused,omitempty"`
1599 // dropped counts conversations this host could not carry to the guest — no
1600 // guest address yet, a dial the guest refused, a datagram that would not send
1601 // — as opposed to ones the cap refused.
1602 Dropped int64 `protobuf:"varint,3,opt,name=dropped,proto3" json:"dropped,omitempty"`
1603 unknownFields protoimpl.UnknownFields
1604 sizeCache protoimpl.SizeCache
1605 }
1606
1607 func (x *ExposureSessions) Reset() {
1608 *x = ExposureSessions{}
1609 mi := &file_proto_eitri_v1_sync_proto_msgTypes[18]
1610 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1611 ms.StoreMessageInfo(mi)
1612 }
1613
1614 func (x *ExposureSessions) String() string {
1615 return protoimpl.X.MessageStringOf(x)
1616 }
1617
1618 func (*ExposureSessions) ProtoMessage() {}
1619
1620 func (x *ExposureSessions) ProtoReflect() protoreflect.Message {
1621 mi := &file_proto_eitri_v1_sync_proto_msgTypes[18]
1622 if x != nil {
1623 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1624 if ms.LoadMessageInfo() == nil {
1625 ms.StoreMessageInfo(mi)
1626 }
1627 return ms
1628 }
1629 return mi.MessageOf(x)
1630 }
1631
1632 // Deprecated: Use ExposureSessions.ProtoReflect.Descriptor instead.
1633 func (*ExposureSessions) Descriptor() ([]byte, []int) {
1634 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{18}
1635 }
1636
1637 func (x *ExposureSessions) GetActive() int64 {
1638 if x != nil {
1639 return x.Active
1640 }
1641 return 0
1642 }
1643
1644 func (x *ExposureSessions) GetRefused() int64 {
1645 if x != nil {
1646 return x.Refused
1647 }
1648 return 0
1649 }
1650
1651 func (x *ExposureSessions) GetDropped() int64 {
1652 if x != nil {
1653 return x.Dropped
1654 }
1655 return 0
1656 }
1657
1552 var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor 1658 var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor
1553 1659
1554 const file_proto_eitri_v1_sync_proto_rawDesc = "" + 1660 const file_proto_eitri_v1_sync_proto_rawDesc = "" +
@@ -1602,7 +1708,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1602 "\fdisk_used_gb\x18\a \x01(\x03R\n" + 1708 "\fdisk_used_gb\x18\a \x01(\x03R\n" +
1603 "diskUsedGb\x12 \n" + 1709 "diskUsedGb\x12 \n" +
1604 "\fdisk_free_gb\x18\b \x01(\x03R\n" + 1710 "\fdisk_free_gb\x18\b \x01(\x03R\n" +
1605 "diskFreeGb\"\xa2\x01\n" + 1711 "diskFreeGb\"\xc7\x01\n" +
1606 "\bActualVM\x12\x13\n" + 1712 "\bActualVM\x12\x13\n" +
1607 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" + 1713 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" +
1608 "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" + 1714 "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" +
@@ -1610,7 +1716,8 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1610 "\x02ip\x18\x04 \x01(\tR\x02ip\x12\x1d\n" + 1716 "\x02ip\x18\x04 \x01(\tR\x02ip\x12\x1d\n" +
1611 "\n" + 1717 "\n" +
1612 "last_error\x18\x05 \x01(\tR\tlastError\x12&\n" + 1718 "last_error\x18\x05 \x01(\tR\tlastError\x12&\n" +
1613 "\x0fssh_host_pubkey\x18\x06 \x01(\tR\rsshHostPubkey\"\x81\x01\n" + 1719 "\x0fssh_host_pubkey\x18\x06 \x01(\tR\rsshHostPubkey\x12#\n" +
1720 "\rstatus_detail\x18\a \x01(\tR\fstatusDetail\"\x81\x01\n" +
1614 "\rQuarantinedVM\x12\x13\n" + 1721 "\rQuarantinedVM\x12\x13\n" +
1615 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + 1722 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" +
1616 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + 1723 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" +
@@ -1679,11 +1786,16 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1679 "\n" + 1786 "\n" +
1680 "guest_port\x18\x03 \x01(\rR\tguestPort\x12\x1b\n" + 1787 "guest_port\x18\x03 \x01(\rR\tguestPort\x12\x1b\n" +
1681 "\thost_port\x18\x04 \x01(\rR\bhostPort\x12\x1a\n" + 1788 "\thost_port\x18\x04 \x01(\rR\bhostPort\x12\x1a\n" +
1682 "\bprotocol\x18\x05 \x01(\tR\bprotocol\"N\n" + 1789 "\bprotocol\x18\x05 \x01(\tR\bprotocol\"\x86\x01\n" +
1683 "\x0eExposureActual\x12\x0e\n" + 1790 "\x0eExposureActual\x12\x0e\n" +
1684 "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + 1791 "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
1685 "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" + 1792 "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" +
1686 "\x06reason\x18\x03 \x01(\tR\x06reasonB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" 1793 "\x06reason\x18\x03 \x01(\tR\x06reason\x126\n" +
1794 "\bsessions\x18\x04 \x01(\v2\x1a.eitri.v1.ExposureSessionsR\bsessions\"^\n" +
1795 "\x10ExposureSessions\x12\x16\n" +
1796 "\x06active\x18\x01 \x01(\x03R\x06active\x12\x18\n" +
1797 "\arefused\x18\x02 \x01(\x03R\arefused\x12\x18\n" +
1798 "\adropped\x18\x03 \x01(\x03R\adroppedB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3"
1687 1799
1688 var ( 1800 var (
1689 file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once 1801 file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once
@@ -1697,7 +1809,7 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte {
1697 return file_proto_eitri_v1_sync_proto_rawDescData 1809 return file_proto_eitri_v1_sync_proto_rawDescData
1698 } 1810 }
1699 1811
1700 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 18) 1812 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
1701 var file_proto_eitri_v1_sync_proto_goTypes = []any{ 1813 var file_proto_eitri_v1_sync_proto_goTypes = []any{
1702 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage 1814 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage
1703 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage 1815 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage
@@ -1717,6 +1829,7 @@ var file_proto_eitri_v1_sync_proto_goTypes = []any{
1717 (*TCPOpened)(nil), // 15: eitri.v1.TCPOpened 1829 (*TCPOpened)(nil), // 15: eitri.v1.TCPOpened
1718 (*ExposureDesired)(nil), // 16: eitri.v1.ExposureDesired 1830 (*ExposureDesired)(nil), // 16: eitri.v1.ExposureDesired
1719 (*ExposureActual)(nil), // 17: eitri.v1.ExposureActual 1831 (*ExposureActual)(nil), // 17: eitri.v1.ExposureActual
1832 (*ExposureSessions)(nil), // 18: eitri.v1.ExposureSessions
1720 } 1833 }
1721 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ 1834 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
1722 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello 1835 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello
@@ -1736,11 +1849,12 @@ var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
1736 9, // 14: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired 1849 9, // 14: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired
1737 11, // 15: eitri.v1.DesiredStateSnapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade 1850 11, // 15: eitri.v1.DesiredStateSnapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade
1738 16, // 16: eitri.v1.DesiredStateSnapshot.exposures:type_name -> eitri.v1.ExposureDesired 1851 16, // 16: eitri.v1.DesiredStateSnapshot.exposures:type_name -> eitri.v1.ExposureDesired
1739 17, // [17:17] is the sub-list for method output_type 1852 18, // 17: eitri.v1.ExposureActual.sessions:type_name -> eitri.v1.ExposureSessions
1740 17, // [17:17] is the sub-list for method input_type 1853 18, // [18:18] is the sub-list for method output_type
1741 17, // [17:17] is the sub-list for extension type_name 1854 18, // [18:18] is the sub-list for method input_type
1742 17, // [17:17] is the sub-list for extension extendee 1855 18, // [18:18] is the sub-list for extension type_name
1743 0, // [0:17] is the sub-list for field type_name 1856 18, // [18:18] is the sub-list for extension extendee
1857 0, // [0:18] is the sub-list for field type_name
1744 } 1858 }
1745 1859
1746 func init() { file_proto_eitri_v1_sync_proto_init() } 1860 func init() { file_proto_eitri_v1_sync_proto_init() }
@@ -1765,7 +1879,7 @@ func file_proto_eitri_v1_sync_proto_init() {
1765 GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 1879 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
1766 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), 1880 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)),
1767 NumEnums: 0, 1881 NumEnums: 0,
1768 NumMessages: 18, 1882 NumMessages: 19,
1769 NumExtensions: 0, 1883 NumExtensions: 0,
1770 NumServices: 0, 1884 NumServices: 0,
1771 }, 1885 },
internal/server/api/api.go
Old New
@@ -567,27 +567,28 @@ func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) {
567 567
568 // toVMResponse merges a durable VM row with live agent-reported actual-state 568 // toVMResponse merges a durable VM row with live agent-reported actual-state
569 // into the wire shape (types.VM). 569 // into the wire shape (types.VM).
570 func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types.VM { 570 func toVMResponse(vm store.VM, actualPower, phase, statusDetail string, destroyAt int64) types.VM {
571 return types.VM{ 571 return types.VM{
572 ID: vm.ID, 572 ID: vm.ID,
573 HostID: vm.HostID, 573 HostID: vm.HostID,
574 Name: vm.Name, 574 Name: vm.Name,
575 ImageURL: vm.ImageURL, 575 ImageURL: vm.ImageURL,
576 VCPUs: vm.VCPUs, 576 VCPUs: vm.VCPUs,
577 MemMB: vm.MemMB, 577 MemMB: vm.MemMB,
578 DiskGB: vm.DiskGB, 578 DiskGB: vm.DiskGB,
579 PowerState: vm.PowerState, 579 PowerState: vm.PowerState,
580 Status: vm.Status, 580 Status: vm.Status,
581 LastError: vm.LastError, 581 LastError: vm.LastError,
582 AssignedIP: vm.AssignedIP, 582 AssignedIP: vm.AssignedIP,
583 CreatedAt: vm.CreatedAt, 583 CreatedAt: vm.CreatedAt,
584 Deleted: vm.DeletedAt != nil, 584 Deleted: vm.DeletedAt != nil,
585 ActualPower: actualPower, 585 ActualPower: actualPower,
586 Phase: phase, 586 Phase: phase,
587 DestroyAt: destroyAt, 587 StatusDetail: statusDetail,
588 Lifecycle: deriveLifecycle(vm, actualPower, phase), 588 DestroyAt: destroyAt,
589 InjectedKey: injectedKey(vm), 589 Lifecycle: deriveLifecycle(vm, actualPower, phase),
590 TrustedCAs: trustedCAs(vm), 590 InjectedKey: injectedKey(vm),
591 TrustedCAs: trustedCAs(vm),
591 } 592 }
592 } 593 }
593 594
@@ -639,13 +640,14 @@ func (a *API) snapshotVMs(p Principal) ([]types.VM, error) {
639 func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []types.VM { 640 func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []types.VM {
640 out := make([]types.VM, len(vms)) 641 out := make([]types.VM, len(vms))
641 for i, vm := range vms { 642 for i, vm := range vms {
642 var actualPower, phase string 643 var actualPower, phase, statusDetail string
643 var destroyAt int64 644 var destroyAt int64
644 if rs := states[vm.HostID]; rs.ok { 645 if rs := states[vm.HostID]; rs.ok {
645 for _, av := range rs.st.Report.VMs { 646 for _, av := range rs.st.Report.VMs {
646 if av.VMID == vm.ID { 647 if av.VMID == vm.ID {
647 actualPower = av.Power 648 actualPower = av.Power
648 phase = av.Phase 649 phase = av.Phase
650 statusDetail = av.StatusDetail
649 break 651 break
650 } 652 }
651 } 653 }
@@ -658,7 +660,7 @@ func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []typ
658 } 660 }
659 } 661 }
660 } 662 }
661 out[i] = toVMResponse(vm, actualPower, phase, destroyAt) 663 out[i] = toVMResponse(vm, actualPower, phase, statusDetail, destroyAt)
662 } 664 }
663 return out 665 return out
664 } 666 }
internal/server/api/exposures.go
Old New
@@ -72,24 +72,35 @@ func exposureProtocol(req types.CreateExposureRequest) string {
72 // exposureState folds a host's live report into one exposure's state. An 72 // exposureState folds a host's live report into one exposure's state. An
73 // exposure the host has not reported on is "pending": the grant exists, and 73 // exposure the host has not reported on is "pending": the grant exists, and
74 // nothing has said what the host made of it yet. 74 // nothing has said what the host made of it yet.
75 func exposureState(statuses []registry.ExposureStatus, id string) (state, reason string) { 75 func exposureState(statuses []registry.ExposureStatus, id string) (state, reason string, sessions *types.ExposureSessions) {
76 for _, s := range statuses { 76 for _, s := range statuses {
77 if s.ID == id { 77 if s.ID == id {
78 return s.State, s.Reason 78 return s.State, s.Reason, toExposureSessions(s.Sessions)
79 } 79 }
80 } 80 }
81 return "pending", "" 81 return "pending", "", nil
82 }
83
84 // toExposureSessions maps a host's reported counters to the wire shape, and
85 // keeps nil meaning nil the whole way: an exposure nobody has reported on, and
86 // one served by an agent that predates the counters, both say nothing rather
87 // than saying zero.
88 func toExposureSessions(s *registry.ExposureSessions) *types.ExposureSessions {
89 if s == nil {
90 return nil
91 }
92 return &types.ExposureSessions{Active: s.Active, Refused: s.Refused, Dropped: s.Dropped}
82 } 93 }
83 94
84 // toExposureResponse merges a durable exposure row with its host's address and 95 // toExposureResponse merges a durable exposure row with its host's address and
85 // live reported state into the wire shape. 96 // live reported state into the wire shape.
86 func toExposureResponse(e store.Exposure, hostAddr string, statuses []registry.ExposureStatus) types.Exposure { 97 func toExposureResponse(e store.Exposure, hostAddr string, statuses []registry.ExposureStatus) types.Exposure {
87 state, reason := exposureState(statuses, e.ID) 98 state, reason, sessions := exposureState(statuses, e.ID)
88 return types.Exposure{ 99 return types.Exposure{
89 ID: e.ID, VMID: e.VMID, HostID: e.HostID, 100 ID: e.ID, VMID: e.VMID, HostID: e.HostID,
90 GuestPort: e.GuestPort, HostPort: e.HostPort, HostAddr: hostAddr, 101 GuestPort: e.GuestPort, HostPort: e.HostPort, HostAddr: hostAddr,
91 Protocol: e.Protocol, Scope: e.Scope, 102 Protocol: e.Protocol, Scope: e.Scope,
92 State: state, Reason: reason, CreatedAt: e.CreatedAt, 103 State: state, Reason: reason, Sessions: sessions, CreatedAt: e.CreatedAt,
93 } 104 }
94 } 105 }
95 106
internal/server/api/narration_test.go
Old New
@@ -0,0 +1,134 @@
1 package api
2
3 import (
4 "encoding/json"
5 "testing"
6
7 "github.com/a73x/eitri/internal/server/registry"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // vmByID pulls one VM out of the list response.
13 func vmByID(t *testing.T, ts string, id string) map[string]any {
14 t.Helper()
15 resp := do(t, "GET", ts+"/api/v1/vms", testPAT, nil)
16 require.Equal(t, 200, resp.StatusCode)
17 var list []map[string]any
18 require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
19 for _, vm := range list {
20 if vm["id"] == id {
21 return vm
22 }
23 }
24 t.Fatalf("vm %s not in the listing", id)
25 return nil
26 }
27
28 // TestWhatAHostIsDoingReachesTheWire is the outbound leg of the narration: the
29 // sentence the host reported is served beside the phase, so the console can put
30 // it under a VM that would otherwise say only "creating".
31 func TestWhatAHostIsDoingReachesTheWire(t *testing.T) {
32 ts, _, _, reg, _ := newServer(t)
33 host := enroll(t, ts)
34 vmID := createTestVM(t, ts, host["host_id"], "web-1")
35
36 reg.UpdateReport(host["host_id"], registry.Report{
37 VMs: []registry.ActualVM{{
38 VMID: vmID, Power: "stopped", Phase: "creating",
39 StatusDetail: "downloading image 1.2/3.7 GiB",
40 }},
41 })
42
43 vm := vmByID(t, ts.URL, vmID)
44 assert.Equal(t, "creating", vm["phase"])
45 assert.Equal(t, "downloading image 1.2/3.7 GiB", vm["status_detail"])
46 }
47
48 // TestAVMOnASilentAgentReadsAsItAlwaysDid is the fielded-agent case end to end:
49 // a host that reports no detail — an agent older than the field — serves an
50 // empty one, and the console renders exactly what it rendered before.
51 func TestAVMOnASilentAgentReadsAsItAlwaysDid(t *testing.T) {
52 ts, _, _, reg, _ := newServer(t)
53 host := enroll(t, ts)
54 vmID := createTestVM(t, ts, host["host_id"], "web-1")
55
56 reg.UpdateReport(host["host_id"], registry.Report{
57 VMs: []registry.ActualVM{{VMID: vmID, Power: "stopped", Phase: "creating"}},
58 })
59
60 vm := vmByID(t, ts.URL, vmID)
61 assert.Equal(t, "creating", vm["phase"])
62 assert.Equal(t, "", vm["status_detail"])
63 }
64
65 // TestExposureSessionCountersReachTheWire pins the counters' outbound leg: what
66 // the host counted is what the exposure row serves.
67 func TestExposureSessionCountersReachTheWire(t *testing.T) {
68 ts, _, _, reg, _ := newServer(t)
69 host := enroll(t, ts)
70 vmID := createTestVM(t, ts, host["host_id"], "web-1")
71
72 resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
73 map[string]any{"guest_port": 8080, "host_port": 30080})
74 require.Equal(t, 201, resp.StatusCode)
75 var created map[string]any
76 require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
77
78 reg.UpdateReport(host["host_id"], registry.Report{
79 Exposures: []registry.ExposureStatus{{
80 ID: created["id"].(string), State: "active",
81 Sessions: &registry.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
82 }},
83 })
84
85 resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
86 require.Equal(t, 200, resp.StatusCode)
87 var list []map[string]any
88 require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
89 require.Len(t, list, 1)
90
91 sessions, ok := list[0]["sessions"].(map[string]any)
92 require.True(t, ok, "a counted exposure carries its counters")
93 assert.Equal(t, float64(7), sessions["active"])
94 assert.Equal(t, float64(12), sessions["refused"])
95 assert.Equal(t, float64(3), sessions["dropped"])
96 }
97
98 // TestAnUncountedExposureServesNoCounters is the case that decides the console's
99 // display rule. Two exposures nobody counted: one on an agent too old to count
100 // (reported, no counters), one nothing has reported on at all. Both serve null
101 // rather than zeros, because a row of zeros is a claim — "this port has turned
102 // nobody away" — that nothing on the wire supports.
103 func TestAnUncountedExposureServesNoCounters(t *testing.T) {
104 ts, _, _, reg, _ := newServer(t)
105 host := enroll(t, ts)
106 vmID := createTestVM(t, ts, host["host_id"], "web-1")
107
108 newExposure := func(guestPort, hostPort int64) string {
109 t.Helper()
110 resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
111 map[string]any{"guest_port": guestPort, "host_port": hostPort})
112 require.Equal(t, 201, resp.StatusCode)
113 var e map[string]any
114 require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))
115 return e["id"].(string)
116 }
117 reported := newExposure(8080, 30080)
118 newExposure(9090, 30081) // never reported on
119
120 reg.UpdateReport(host["host_id"], registry.Report{
121 Exposures: []registry.ExposureStatus{{ID: reported, State: "active"}},
122 })
123
124 resp := do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
125 require.Equal(t, 200, resp.StatusCode)
126 var list []map[string]any
127 require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
128 require.Len(t, list, 2)
129
130 assert.Equal(t, "active", list[0]["state"])
131 assert.Nil(t, list[0]["sessions"], "an agent that counts nothing reports nothing")
132 assert.Equal(t, "pending", list[1]["state"])
133 assert.Nil(t, list[1]["sessions"], "an unreported exposure has no counters either")
134 }
internal/server/api/testdata/exposure.golden.json
Old New
@@ -10,6 +10,11 @@
10 "scope": "lan", 10 "scope": "lan",
11 "state": "failed", 11 "state": "failed",
12 "reason": "listen tcp 0.0.0.0:30080: bind: address already in use", 12 "reason": "listen tcp 0.0.0.0:30080: bind: address already in use",
13 "created_at": "2026-07-27T12:04:00Z" 13 "created_at": "2026-07-27T12:04:00Z",
14 "sessions": {
15 "active": 7,
16 "refused": 12,
17 "dropped": 3
18 }
14 } 19 }
15 ] 20 ]
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -66,6 +66,7 @@
66 "deleted": true, 66 "deleted": true,
67 "actual_power": "stopped", 67 "actual_power": "stopped",
68 "phase": "creating", 68 "phase": "creating",
69 "status_detail": "downloading image 1.2/3.7 GiB",
69 "destroy_at": 1785153600, 70 "destroy_at": 1785153600,
70 "lifecycle": "deleting", 71 "lifecycle": "deleting",
71 "injected_key": { 72 "injected_key": {
internal/server/api/testdata/vm.golden.json
Old New
@@ -14,6 +14,7 @@
14 "deleted": true, 14 "deleted": true,
15 "actual_power": "stopped", 15 "actual_power": "stopped",
16 "phase": "creating", 16 "phase": "creating",
17 "status_detail": "downloading image 1.2/3.7 GiB",
17 "destroy_at": 1785153600, 18 "destroy_at": 1785153600,
18 "lifecycle": "deleting", 19 "lifecycle": "deleting",
19 "injected_key": { 20 "injected_key": {
internal/server/api/types/types.go
Old New
@@ -119,6 +119,16 @@ type VM struct {
119 Deleted bool `json:"deleted"` 119 Deleted bool `json:"deleted"`
120 ActualPower string `json:"actual_power"` 120 ActualPower string `json:"actual_power"`
121 Phase string `json:"phase"` 121 Phase string `json:"phase"`
122 // StatusDetail is what this VM's host is doing about it right now, in the
123 // host's own words: "downloading image 1.2/3.7 GiB", "preparing root disk",
124 // "booting". It exists because `creating` is one word for minutes of work
125 // that only the host can see inside.
126 //
127 // Free text for reading, never for branching: it is the host's sentence, and
128 // the control plane neither parses nor validates it. Empty means "nothing to
129 // add" — a settled VM, or a host too old to say — and a client shows nothing
130 // rather than a placeholder.
131 StatusDetail string `json:"status_detail"`
122 // DestroyAt is the unix-seconds deadline at which the agent will hard-destroy 132 // DestroyAt is the unix-seconds deadline at which the agent will hard-destroy
123 // this VM. It is only set while the VM is quarantined for teardown (deleted + 133 // this VM. It is only set while the VM is quarantined for teardown (deleted +
124 // guest stopped, awaiting the tombstone grace window); 0 in the normal case. 134 // guest stopped, awaiting the tombstone grace window); 0 in the normal case.
@@ -303,6 +313,27 @@ type Exposure struct {
303 State string `json:"state"` 313 State string `json:"state"`
304 Reason string `json:"reason"` 314 Reason string `json:"reason"`
305 CreatedAt time.Time `json:"created_at"` 315 CreatedAt time.Time `json:"created_at"`
316 // Sessions is what the host says this port has carried, or null when the
317 // host has not said — an exposure not yet reported on, or an agent older
318 // than the counters. Null is not zero: a client shows nothing at all rather
319 // than presenting zeros nobody reported.
320 Sessions *ExposureSessions `json:"sessions"`
321 }
322
323 // ExposureSessions is one published port's traffic as its host counts it.
324 // Active is a gauge — conversations open this instant. Refused and Dropped are
325 // running totals SINCE THAT AGENT STARTED, because what they count is momentary
326 // and a gauge would read zero between two bursts of it. An agent restart resets
327 // both, which is why a client says what they are counted from.
328 type ExposureSessions struct {
329 // Active is what the port holds right now: open TCP connections, or live UDP
330 // client sessions.
331 Active int64 `json:"active"`
332 // Refused counts callers the port turned away because it was at its cap.
333 Refused int64 `json:"refused"`
334 // Dropped counts conversations the host could not carry to the guest: no
335 // guest address yet, a refused dial, a datagram that would not send.
336 Dropped int64 `json:"dropped"`
306 } 337 }
307 338
308 // The response shapes below replace handlers' inline map[string]string 339 // The response shapes below replace handlers' inline map[string]string
internal/server/api/wire_golden_test.go
Old New
@@ -91,23 +91,24 @@ func TestWireGolden(t *testing.T) {
91 goldenCheck(t, "host", host) 91 goldenCheck(t, "host", host)
92 92
93 vm := types.VM{ 93 vm := types.VM{
94 ID: "v-5678", 94 ID: "v-5678",
95 HostID: "h-1234", 95 HostID: "h-1234",
96 Name: "sandbox-abc123", 96 Name: "sandbox-abc123",
97 ImageURL: "https://images.example.com/resolute.img", 97 ImageURL: "https://images.example.com/resolute.img",
98 VCPUs: 2, 98 VCPUs: 2,
99 MemMB: 2048, 99 MemMB: 2048,
100 DiskGB: 10, 100 DiskGB: 10,
101 PowerState: "running", 101 PowerState: "running",
102 Status: "ready", 102 Status: "ready",
103 LastError: "boot timeout", 103 LastError: "boot timeout",
104 AssignedIP: "10.77.1.2", 104 AssignedIP: "10.77.1.2",
105 CreatedAt: base.Add(time.Minute), 105 CreatedAt: base.Add(time.Minute),
106 Deleted: true, 106 Deleted: true,
107 ActualPower: "stopped", 107 ActualPower: "stopped",
108 Phase: "creating", 108 Phase: "creating",
109 DestroyAt: 1785153600, 109 StatusDetail: "downloading image 1.2/3.7 GiB",
110 Lifecycle: "deleting", 110 DestroyAt: 1785153600,
111 Lifecycle: "deleting",
111 InjectedKey: &types.InjectedKey{ 112 InjectedKey: &types.InjectedKey{
112 Type: "ssh-ed25519", 113 Type: "ssh-ed25519",
113 Fingerprint: "SHA256:0000000000000000000000000000000000000000000", 114 Fingerprint: "SHA256:0000000000000000000000000000000000000000000",
@@ -278,6 +279,7 @@ func TestWireGolden(t *testing.T) {
278 Scope: "lan", 279 Scope: "lan",
279 State: "failed", 280 State: "failed",
280 Reason: "listen tcp 0.0.0.0:30080: bind: address already in use", 281 Reason: "listen tcp 0.0.0.0:30080: bind: address already in use",
282 Sessions: &types.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
281 CreatedAt: base.Add(4 * time.Minute), 283 CreatedAt: base.Add(4 * time.Minute),
282 }}) 284 }})
283 } 285 }
internal/server/registry/registry.go
Old New
@@ -38,6 +38,10 @@ type Metrics struct {
38 38
39 type ActualVM struct { 39 type ActualVM struct {
40 VMID, Power, Phase, IP, LastError string 40 VMID, Power, Phase, IP, LastError string
41 // StatusDetail is what the host is doing about this VM right now, in the
42 // host's own words. Empty is the normal state of a settled VM, and is also
43 // what an agent too old to say anything leaves behind.
44 StatusDetail string
41 } 45 }
42 46
43 type QuarantinedVM struct { 47 type QuarantinedVM struct {
@@ -51,7 +55,19 @@ type QuarantinedVM struct {
51 // otherwise. Never persisted — like Metrics, it lives only while the host is 55 // otherwise. Never persisted — like Metrics, it lives only while the host is
52 // connected, and an exposure with no row here has simply not been reported on 56 // connected, and an exposure with no row here has simply not been reported on
53 // yet. 57 // yet.
54 type ExposureStatus struct{ ID, State, Reason string } 58 type ExposureStatus struct {
59 ID, State, Reason string
60 // Sessions is what the port has carried, or nil when the agent serving it
61 // does not count — an agent older than the counters. Nil is deliberately
62 // distinguishable from a zeroed struct: one is "nobody said", the other is
63 // "nothing has happened", and only the second is a fact worth showing.
64 Sessions *ExposureSessions
65 }
66
67 // ExposureSessions is one published port's traffic as its host counts it:
68 // Active right now, Refused and Dropped cumulatively since that agent started.
69 // See ExposureSessions in the proto for why the two totals are not gauges.
70 type ExposureSessions struct{ Active, Refused, Dropped int64 }
55 71
56 type Report struct { 72 type Report struct {
57 VMs []ActualVM 73 VMs []ActualVM
internal/server/syncsvc/narration_test.go
Old New
@@ -0,0 +1,85 @@
1 package syncsvc
2
3 import (
4 "testing"
5
6 "github.com/a73x/eitri/internal/pb"
7 "github.com/a73x/eitri/internal/server/registry"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // TestApplyReportCarriesWhatAHostIsDoing pins the inbound leg of the narration:
13 // what a host says it is doing about a VM lands in the registry beside the
14 // phase, where the VM API reads it.
15 func TestApplyReportCarriesWhatAHostIsDoing(t *testing.T) {
16 f := setup(t)
17
18 f.svc.applyReport(f.host.ID, &pb.ActualStateReport{
19 Vms: []*pb.ActualVM{{
20 VmId: "vm1", Power: "stopped", Phase: "creating",
21 StatusDetail: "downloading image 1.2/3.7 GiB",
22 }},
23 })
24
25 got, ok := f.reg.Get(f.host.ID)
26 require.True(t, ok)
27 require.Len(t, got.Report.VMs, 1)
28 assert.Equal(t, "downloading image 1.2/3.7 GiB", got.Report.VMs[0].StatusDetail)
29 }
30
31 // TestApplyReportFromAnAgentThatSaysNothing is the fielded-agent case: an agent
32 // that predates status_detail sends none, and the field is absent rather than
33 // wrong. Everything about the VM must read exactly as it did before the field
34 // existed.
35 func TestApplyReportFromAnAgentThatSaysNothing(t *testing.T) {
36 f := setup(t)
37
38 f.svc.applyReport(f.host.ID, &pb.ActualStateReport{
39 Vms: []*pb.ActualVM{{VmId: "vm1", Power: "stopped", Phase: "creating"}},
40 })
41
42 got, ok := f.reg.Get(f.host.ID)
43 require.True(t, ok)
44 require.Len(t, got.Report.VMs, 1)
45 assert.Equal(t, "creating", got.Report.VMs[0].Phase)
46 assert.Empty(t, got.Report.VMs[0].StatusDetail, "an agent that says nothing must not be quoted")
47 }
48
49 // TestApplyReportCountsExposureSessions pins the counters' inbound leg, gauge
50 // and totals together.
51 func TestApplyReportCountsExposureSessions(t *testing.T) {
52 f := setup(t)
53
54 f.svc.applyReport(f.host.ID, &pb.ActualStateReport{
55 Exposures: []*pb.ExposureActual{{
56 Id: "e1", State: "active",
57 Sessions: &pb.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
58 }},
59 })
60
61 got, ok := f.reg.Get(f.host.ID)
62 require.True(t, ok)
63 require.Len(t, got.Report.Exposures, 1)
64 require.NotNil(t, got.Report.Exposures[0].Sessions)
65 assert.Equal(t, registry.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
66 *got.Report.Exposures[0].Sessions)
67 }
68
69 // TestApplyReportFromAnAgentThatCountsNothing is the other half of the fielded-
70 // agent case, and the one where zero would be a lie: an agent that reports no
71 // counters leaves nil, NOT a zeroed struct. "Nobody said" and "nothing has
72 // happened" are different answers, and only the second one is a fact.
73 func TestApplyReportFromAnAgentThatCountsNothing(t *testing.T) {
74 f := setup(t)
75
76 f.svc.applyReport(f.host.ID, &pb.ActualStateReport{
77 Exposures: []*pb.ExposureActual{{Id: "e1", State: "active"}},
78 })
79
80 got, ok := f.reg.Get(f.host.ID)
81 require.True(t, ok)
82 require.Len(t, got.Report.Exposures, 1)
83 assert.Equal(t, "active", got.Report.Exposures[0].State)
84 assert.Nil(t, got.Report.Exposures[0].Sessions, "an uncounted port must not report zeros")
85 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -617,11 +617,12 @@ func toRegistryVMs(in []*pb.ActualVM) []registry.ActualVM {
617 out := make([]registry.ActualVM, 0, len(in)) 617 out := make([]registry.ActualVM, 0, len(in))
618 for _, v := range in { 618 for _, v := range in {
619 out = append(out, registry.ActualVM{ 619 out = append(out, registry.ActualVM{
620 VMID: v.GetVmId(), 620 VMID: v.GetVmId(),
621 Power: v.GetPower(), 621 Power: v.GetPower(),
622 Phase: v.GetPhase(), 622 Phase: v.GetPhase(),
623 IP: v.GetIp(), 623 IP: v.GetIp(),
624 LastError: v.GetLastError(), 624 LastError: v.GetLastError(),
625 StatusDetail: v.GetStatusDetail(),
625 }) 626 })
626 } 627 }
627 return out 628 return out
@@ -655,11 +656,25 @@ func toRegistryExposures(in []*pb.ExposureActual) []registry.ExposureStatus {
655 for _, e := range in { 656 for _, e := range in {
656 out = append(out, registry.ExposureStatus{ 657 out = append(out, registry.ExposureStatus{
657 ID: e.GetId(), State: e.GetState(), Reason: e.GetReason(), 658 ID: e.GetId(), State: e.GetState(), Reason: e.GetReason(),
659 Sessions: toRegistrySessions(e.GetSessions()),
658 }) 660 })
659 } 661 }
660 return out 662 return out
661 } 663 }
662 664
665 // toRegistrySessions maps an exposure's reported counters, keeping the one
666 // distinction the whole field exists for: an agent that reports no counters
667 // (nil) is not an exposure that has counted zero, and it must not become one on
668 // the way through.
669 func toRegistrySessions(s *pb.ExposureSessions) *registry.ExposureSessions {
670 if s == nil {
671 return nil
672 }
673 return &registry.ExposureSessions{
674 Active: s.GetActive(), Refused: s.GetRefused(), Dropped: s.GetDropped(),
675 }
676 }
677
663 // toRegistryCapacity maps reported capacity (nil → zero value). 678 // toRegistryCapacity maps reported capacity (nil → zero value).
664 func toRegistryCapacity(c *pb.Capacity) registry.Capacity { 679 func toRegistryCapacity(c *pb.Capacity) registry.Capacity {
665 if c == nil { 680 if c == nil {
internal/transport/contract_test.go
Old New
@@ -7,6 +7,7 @@ import (
7 "github.com/a73x/eitri/internal/pb" 7 "github.com/a73x/eitri/internal/pb"
8 "github.com/stretchr/testify/assert" 8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require" 9 "github.com/stretchr/testify/require"
10 "google.golang.org/protobuf/encoding/protowire"
10 "google.golang.org/protobuf/proto" 11 "google.golang.org/protobuf/proto"
11 ) 12 )
12 13
@@ -21,7 +22,12 @@ func TestAgentMessageReportRoundTrip(t *testing.T) {
21 Vms: []*pb.ActualVM{ 22 Vms: []*pb.ActualVM{
22 {VmId: "vm-1", Power: "on", Phase: "running", Ip: "10.0.0.5", LastError: ""}, 23 {VmId: "vm-1", Power: "on", Phase: "running", Ip: "10.0.0.5", LastError: ""},
23 {VmId: "vm-2", Power: "off", Phase: "stopped"}, 24 {VmId: "vm-2", Power: "off", Phase: "stopped"},
25 {VmId: "vm-3", Power: "off", Phase: "creating", StatusDetail: "downloading image 1.2/3.7 GiB"},
24 }, 26 },
27 Exposures: []*pb.ExposureActual{{
28 Id: "e-1", State: "active",
29 Sessions: &pb.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
30 }},
25 Destroyed: []string{"vm-old"}, 31 Destroyed: []string{"vm-old"},
26 Quarantined: []*pb.QuarantinedVM{{VmId: "vm-q", Name: "q", VmspecJson: []byte(`{"k":1}`), DestroyAtUnix: 1750000000}}, 32 Quarantined: []*pb.QuarantinedVM{{VmId: "vm-q", Name: "q", VmspecJson: []byte(`{"k":1}`), DestroyAtUnix: 1750000000}},
27 Capacity: &pb.Capacity{Vcpus: 16, MemMb: 32768, DiskGb: 500}, 33 Capacity: &pb.Capacity{Vcpus: 16, MemMb: 32768, DiskGb: 500},
@@ -50,6 +56,46 @@ func TestServerMessageSnapshotRoundTrip(t *testing.T) {
50 assert.True(t, proto.Equal(in, out), "snapshot did not survive round-trip:\n in=%v\nout=%v", in, out) 56 assert.True(t, proto.Equal(in, out), "snapshot did not survive round-trip:\n in=%v\nout=%v", in, out)
51 } 57 }
52 58
59 // TestAReportFromANewerAgentDecodesOnAnOlderServer pins the rule the field
60 // numbering exists to serve. A mixed fleet is the normal state during a
61 // rollout, so a peer that has never heard of a field must skip past it and read
62 // everything else — not reject the frame, and not misread the field it does
63 // know. Field 99 is nothing in this schema and stands in for whatever a later
64 // release adds beside status_detail.
65 func TestAReportFromANewerAgentDecodesOnAnOlderServer(t *testing.T) {
66 raw, err := proto.Marshal(&pb.ActualVM{
67 VmId: "vm-1", Power: "off", Phase: "creating",
68 StatusDetail: "downloading image 1.2/3.7 GiB",
69 })
70 require.NoError(t, err)
71 raw = protowire.AppendTag(raw, 99, protowire.BytesType)
72 raw = protowire.AppendString(raw, "a field from a release this peer predates")
73
74 var got pb.ActualVM
75 require.NoError(t, proto.Unmarshal(raw, &got), "an unknown field must not fail the frame")
76 assert.Equal(t, "vm-1", got.GetVmId())
77 assert.Equal(t, "creating", got.GetPhase())
78 assert.Equal(t, "downloading image 1.2/3.7 GiB", got.GetStatusDetail())
79 }
80
81 // TestAReportFromAnOlderAgentReadsAsSilence is the same rule from the other
82 // side, and the one the console's display rules rest on: an agent that predates
83 // these fields sends neither, and neither may arrive as a value. An absent
84 // status_detail is empty (nothing to add) and absent counters are NIL — not a
85 // zeroed struct claiming this port has turned nobody away.
86 func TestAReportFromAnOlderAgentReadsAsSilence(t *testing.T) {
87 raw, err := proto.Marshal(&pb.ActualStateReport{
88 Vms: []*pb.ActualVM{{VmId: "vm-1", Power: "off", Phase: "creating"}},
89 Exposures: []*pb.ExposureActual{{Id: "e-1", State: "active"}},
90 })
91 require.NoError(t, err)
92
93 var got pb.ActualStateReport
94 require.NoError(t, proto.Unmarshal(raw, &got))
95 assert.Empty(t, got.GetVms()[0].GetStatusDetail())
96 assert.Nil(t, got.GetExposures()[0].GetSessions(), "an uncounted port must not decode as zeros")
97 }
98
53 // roundTrip frames in and reads it back into out. 99 // roundTrip frames in and reads it back into out.
54 func roundTrip(t *testing.T, in, out proto.Message) { 100 func roundTrip(t *testing.T, in, out proto.Message) {
55 t.Helper() 101 t.Helper()
proto/eitri/v1/sync.proto
Old New
@@ -76,6 +76,15 @@ message ActualVM {
76 // every report for as long as the VM exists (level-triggered), so a lost 76 // every report for as long as the VM exists (level-triggered), so a lost
77 // snapshot or a control-plane restart re-certifies without operator action. 77 // snapshot or a control-plane restart re-certifies without operator action.
78 string ssh_host_pubkey = 6; 78 string ssh_host_pubkey = 6;
79 // What this VM's host is doing about it right now, in the host's own words:
80 // "downloading image 1.2/3.7 GiB", "preparing root disk", "booting". A create
81 // is minutes of work behind one word, and the host is the only thing that can
82 // see inside it — the control plane knows the VM is creating and nothing more.
83 //
84 // Free text for display, never parsed. Empty is the normal state of a settled
85 // VM and means only "nothing to add": an agent that predates this field sends
86 // it never, and a console reading it reads exactly what it read before.
87 string status_detail = 7;
79 } 88 }
80 89
81 message QuarantinedVM { 90 message QuarantinedVM {
@@ -227,4 +236,33 @@ message ExposureActual {
227 string id = 1; 236 string id = 1;
228 string state = 2; // "active"|"failed" 237 string state = 2; // "active"|"failed"
229 string reason = 3; // the OS error, when failed; a bound port's ongoing trouble otherwise 238 string reason = 3; // the OS error, when failed; a bound port's ongoing trouble otherwise
239 ExposureSessions sessions = 4;
240 }
241
242 // ExposureSessions is what one published port has carried since the agent
243 // serving it started: what it holds this instant, and what it has turned away
244 // or could not carry along the way.
245 //
246 // The two totals only ever go up, deliberately. Loss on a published port is
247 // intermittent — a burst at the cap, a guest that went away for a second — and
248 // a gauge sampled once per report would show a clean zero for every one of
249 // those bursts, which reads as a port with nothing wrong. A running total
250 // cannot hide a burst it has already counted.
251 //
252 // A MESSAGE rather than three scalars beside the state, so that its absence is
253 // readable. An agent that predates these counters reports none, which is not
254 // the same fact as an exposure that has counted zero; the console says nothing
255 // rather than presenting a zero it was never told.
256 message ExposureSessions {
257 // active is what the port holds right now — spliced TCP connections, or live
258 // UDP client sessions. The one gauge here, and the only number that goes down.
259 int64 active = 1;
260 // refused counts callers turned away because the port was already at its cap:
261 // a TCP connection accepted and closed, a UDP datagram dropped without
262 // starting a session.
263 int64 refused = 2;
264 // dropped counts conversations this host could not carry to the guest — no
265 // guest address yet, a dial the guest refused, a datagram that would not send
266 // — as opposed to ones the cap refused.
267 int64 dropped = 3;
230 } 268 }
web/src/lib/api-types.ts
Old New
@@ -1579,9 +1579,15 @@ export interface components {
1579 protocol: string; 1579 protocol: string;
1580 reason: string; 1580 reason: string;
1581 scope: string; 1581 scope: string;
1582 sessions?: components["schemas"]["ExposureSessions"] | null;
1582 state: string; 1583 state: string;
1583 vm_id: string; 1584 vm_id: string;
1584 }; 1585 };
1586 ExposureSessions: {
1587 active: number;
1588 dropped: number;
1589 refused: number;
1590 };
1585 Host: { 1591 Host: {
1586 agent_update_available: boolean; 1592 agent_update_available: boolean;
1587 agent_version: string; 1593 agent_version: string;
@@ -1697,6 +1703,7 @@ export interface components {
1697 phase: string; 1703 phase: string;
1698 power_state: string; 1704 power_state: string;
1699 status: string; 1705 status: string;
1706 status_detail: string;
1700 trusted_cas?: components["schemas"]["TrustedCA"][] | null; 1707 trusted_cas?: components["schemas"]["TrustedCA"][] | null;
1701 vcpus: number; 1708 vcpus: number;
1702 }; 1709 };
web/src/lib/fleet.svelte.ts
Old New
@@ -490,6 +490,22 @@ export function vmPower(vm: VM): string {
490 return vm.actual_power || vm.power_state; 490 return vm.actual_power || vm.power_state;
491 } 491 }
492 492
493 /** vmDetail is the sentence to show beneath a VM's status: what its host is
494 * doing about it right now, in the host's own words.
495 *
496 * Only a VM that is still being created gets one. The sentence describes work
497 * in flight — "downloading image 1.2/3.7 GiB" — and a settled VM showing the
498 * last thing its host was doing would be presenting the past as the present.
499 * Everything else returns '' and the row is not drawn at all.
500 *
501 * An empty detail is the normal case in two situations that look identical
502 * here and should: a host with nothing to add, and a host running an agent old
503 * enough that it never sends the field. Neither gets a placeholder. */
504 export function vmDetail(vm: VM): string {
505 if (vmStatus(vm) !== 'creating') return '';
506 return vm.status_detail ?? '';
507 }
508
493 /** vmIP is the assigned IP, or an em-dash placeholder when unassigned. */ 509 /** vmIP is the assigned IP, or an em-dash placeholder when unassigned. */
494 export function vmIP(vm: VM): string { 510 export function vmIP(vm: VM): string {
495 return vm.assigned_ip || '—'; 511 return vm.assigned_ip || '—';
@@ -662,6 +678,25 @@ export function formatHostPort(addr: string, port: number): string {
662 return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`; 678 return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`;
663 } 679 }
664 680
681 /** sessionSummary is what an exposure row says about the traffic its port has
682 * carried, or '' when the row should say nothing at all.
683 *
684 * Saying nothing is the interesting half. The counters are absent for an
685 * exposure no host has reported on yet, and for one served by an agent that
686 * predates them — and in both cases the honest answer is silence. Rendering
687 * zeros instead would state that this port has turned nobody away, which is a
688 * claim nothing on the wire supports; a real zero, reported as zero, says
689 * exactly that and is worth showing.
690 *
691 * "since agent start" is not decoration. The two totals reset when the agent
692 * restarts, so a number read without that clause is a number over an unknown
693 * window. */
694 export function sessionSummary(e: Exposure): string {
695 const s = e.sessions;
696 if (!s) return '';
697 return `${s.active} open · ${s.refused} refused, ${s.dropped} dropped since agent start`;
698 }
699
665 /** CapacityReading is one allocation read against the capacity its host 700 /** CapacityReading is one allocation read against the capacity its host
666 * reports: what to draw, and what to say about the difference. */ 701 * reports: what to draw, and what to say about the difference. */
667 export type CapacityReading = { 702 export type CapacityReading = {
web/src/lib/fleet.test.ts
Old New
@@ -4,11 +4,14 @@ import {
4 fleet, 4 fleet,
5 hostBundle, 5 hostBundle,
6 joinCommands, 6 joinCommands,
7 sessionSummary,
7 upgradeAge, 8 upgradeAge,
8 upgradeStuck, 9 upgradeStuck,
9 upgradeStuckNote, 10 upgradeStuckNote,
11 vmDetail,
10 vmTrustStale, 12 vmTrustStale,
11 UPGRADE_STUCK_S, 13 UPGRADE_STUCK_S,
14 type Exposure,
12 type Host, 15 type Host,
13 type UserCA, 16 type UserCA,
14 type VM 17 type VM
@@ -44,11 +47,38 @@ function vmTrusting(trusted_cas: TrustedCA[] | null): VM {
44 phase: 'ready', 47 phase: 'ready',
45 power_state: 'running', 48 power_state: 'running',
46 status: 'ready', 49 status: 'ready',
50 status_detail: '',
47 trusted_cas, 51 trusted_cas,
48 vcpus: 1 52 vcpus: 1
49 }; 53 };
50 } 54 }
51 55
56 /** vmCreating is a VM mid-create, carrying whatever its host last said it was
57 * doing. Pass '' for a host with nothing to add — or one whose agent predates
58 * the field and never sends it. */
59 function vmCreating(status_detail: string): VM {
60 return { ...vmTrusting(null), lifecycle: 'creating', phase: 'creating', status_detail };
61 }
62
63 /** exposure is one published port as its row sees it. sessions is null for a
64 * port nobody has counted: unreported, or served by an older agent. */
65 function exposure(sessions: Exposure['sessions']): Exposure {
66 return {
67 created_at: '2026-08-11T09:00:00Z',
68 guest_port: 8080,
69 host_addr: '192.168.0.190',
70 host_id: 'host-1',
71 host_port: 30080,
72 id: 'x-1',
73 protocol: 'tcp',
74 reason: '',
75 scope: 'lan',
76 sessions,
77 state: 'active',
78 vm_id: 'vm-1'
79 };
80 }
81
52 beforeEach(() => { 82 beforeEach(() => {
53 fleet.userCAs = []; 83 fleet.userCAs = [];
54 fleet.userCAsLoaded = false; 84 fleet.userCAsLoaded = false;
@@ -334,3 +364,43 @@ describe('joinCommands', () => {
334 expect(cmds).not.toContain('${V}'); 364 expect(cmds).not.toContain('${V}');
335 }); 365 });
336 }); 366 });
367
368 describe('vmDetail', () => {
369 test('a creating VM shows what its host is doing', () => {
370 expect(vmDetail(vmCreating('downloading image 1.2/3.7 GiB'))).toBe(
371 'downloading image 1.2/3.7 GiB'
372 );
373 });
374
375 test('a host with nothing to say gets no row', () => {
376 expect(vmDetail(vmCreating(''))).toBe('');
377 });
378
379 test('a settled VM says nothing, even carrying a detail', () => {
380 // The sentence describes work in flight. On a ready VM it would be the
381 // past presented as the present.
382 expect(vmDetail({ ...vmTrusting(null), status_detail: 'booting' })).toBe('');
383 });
384 });
385
386 describe('sessionSummary', () => {
387 test('a counted port says what it holds and what it has turned away', () => {
388 expect(sessionSummary(exposure({ active: 7, refused: 12, dropped: 3 }))).toBe(
389 '7 open · 12 refused, 3 dropped since agent start'
390 );
391 });
392
393 test('a counted port with nothing to report still says so', () => {
394 // Reported zeros are a fact: this port has turned nobody away.
395 expect(sessionSummary(exposure({ active: 0, refused: 0, dropped: 0 }))).toBe(
396 '0 open · 0 refused, 0 dropped since agent start'
397 );
398 });
399
400 test('a port nobody counted says nothing rather than zero', () => {
401 // An older agent, or an exposure not yet reported on. Zeros here would
402 // claim the port has refused nobody, which nothing on the wire supports.
403 expect(sessionSummary(exposure(null))).toBe('');
404 expect(sessionSummary(exposure(undefined))).toBe('');
405 });
406 });
web/src/routes/+page.svelte
Old New
@@ -8,6 +8,7 @@
8 decommissionHost, 8 decommissionHost,
9 createJoinBlob, 9 createJoinBlob,
10 vmStatus, 10 vmStatus,
11 vmDetail,
11 restoreVM, 12 restoreVM,
12 vmPower, 13 vmPower,
13 vmIP, 14 vmIP,
@@ -323,9 +324,11 @@
323 <td>{hostName(v.host_id)}</td> 324 <td>{hostName(v.host_id)}</td>
324 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td> 325 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
325 <td> 326 <td>
326 {#if v.deleted}<span class="teardown">deleting—restorable</span>{:else}{vmStatus(v)}{/if}{v.last_error 327 {#if v.deleted}<span class="teardown">deleting—restorable</span>{:else}{vmStatus(v)}{/if}{vmDetail(
327 ? ` · ${v.last_error}` 328 v
328 : ''} 329 )
330 ? ` · ${vmDetail(v)}`
331 : ''}{v.last_error ? ` · ${v.last_error}` : ''}
329 </td> 332 </td>
330 <td>{vmPower(v)}</td> 333 <td>{vmPower(v)}</td>
331 <td class="actions"> 334 <td class="actions">
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -12,6 +12,8 @@
12 teardownApprox, 12 teardownApprox,
13 vmPower, 13 vmPower,
14 vmIP, 14 vmIP,
15 vmDetail,
16 sessionSummary,
15 vmPowerAction, 17 vmPowerAction,
16 deleteConfirm, 18 deleteConfirm,
17 vmEvents, 19 vmEvents,
@@ -236,7 +238,15 @@
236 <tbody> 238 <tbody>
237 <tr><th>ID</th><td>{vm.id}</td></tr> 239 <tr><th>ID</th><td>{vm.id}</td></tr>
238 <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> 240 <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr>
239 <tr><th>Status</th><td>{vmStatus(vm)}</td></tr> 241 <tr>
242 <th>Status</th>
243 <td>
244 {vmStatus(vm)}
245 <!-- What the host is doing about this VM right now, when it is
246 doing something and has said so (see vmDetail). -->
247 {#if vmDetail(vm)}<span class="hint detail">{vmDetail(vm)}</span>{/if}
248 </td>
249 </tr>
240 <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> 250 <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr>
241 <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr> 251 <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr>
242 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> 252 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
@@ -338,6 +348,11 @@
338 {/if} 348 {/if}
339 <span class="state {exposureState(e)}">●</span> {exposureState(e)} 349 <span class="state {exposureState(e)}">●</span> {exposureState(e)}
340 {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if} 350 {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if}
351 <!-- Only a port whose host actually counted gets this line; see
352 sessionSummary. -->
353 {#if sessionSummary(e) && !tearingDown}
354 <span class="hint detail">{sessionSummary(e)}</span>
355 {/if}
341 </td> 356 </td>
342 <td> 357 <td>
343 {#if !tearingDown} 358 {#if !tearingDown}
@@ -405,6 +420,13 @@
405 .err { 420 .err {
406 color: var(--bad); 421 color: var(--bad);
407 } 422 }
423 /* A fact's quieter second half: what is happening under a status, what a
424 published port has carried. It carries the global .hint colour and adds
425 only the line of its own — it is a sentence, not a value, and reading it
426 run together with the fact above it is what a line break prevents. */
427 .detail {
428 display: block;
429 }
408 /* The trusted CAs are a list inside a value cell: unbulleted and flush, so 430 /* The trusted CAs are a list inside a value cell: unbulleted and flush, so
409 the row still reads as one fact with several parts rather than as a 431 the row still reads as one fact with several parts rather than as a
410 nested table. Usually one or two entries. */ 432 nested table. Usually one or two entries. */