a73x

63a6dec6

fix(server): a host keeps saying what it is

a73x   2026-08-06 09:12

Commit message
fix(server): a host keeps saying what it is

The provisioner is written once, at enrollment, and every Hello since has only
logged it. A host that changes backend therefore reports the one it enrolled
with forever: Squirtle enrolled before the macOS backend existed and still names
"inert", a backend that has since been deleted from the tree, to the console,
the CLI and anyone reading the fleet.

It is the same kind of fact as the OS name and the kernel — slow-changing host
identity, refreshed on reconnect — so it travels the path those already take
rather than getting a second one of its own. It drives no behaviour; it is what
an operator reads to know what a box is.

UpdateHostFacts now keeps a column's prior value when the reported one is empty.
Silence is not a statement: it wrote every column unconditionally, so a Hello
carrying no facts — an older agent, or one that failed to gather them — erased
what an earlier agent had said, on every reconnect. A host with genuinely
nothing to report says so in words; a Mac reports virt "none", not "".

internal/server/store/store.go
Old New
@@ -63,6 +63,11 @@ type Host struct {
63 // syncsvc maps pb.HostFacts → store.HostFacts). 63 // syncsvc maps pb.HostFacts → store.HostFacts).
64 type HostFacts struct { 64 type HostFacts struct {
65 OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string 65 OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string
66 // Provisioner is the backend this agent actually runs guests through. It is
67 // recorded at enrollment and refreshed here because it can CHANGE under a
68 // host: a Mac enrolled before its backend existed keeps claiming the one it
69 // enrolled with, naming a backend that may no longer exist in the tree.
70 Provisioner string
66 } 71 }
67 72
68 type VM struct { 73 type VM struct {
@@ -497,10 +502,26 @@ func (s *Store) GetHost(id string) (Host, error) {
497 // failure as non-fatal (it must not drop the connection). Empty fields are 502 // failure as non-fatal (it must not drop the connection). Empty fields are
498 // written as-is: an agent that can no longer read a source clears the stale 503 // written as-is: an agent that can no longer read a source clears the stale
499 // value rather than freezing it. 504 // value rather than freezing it.
505 // UpdateHostFacts refreshes what a host reports about itself. Every column
506 // keeps its prior value when the reported one is EMPTY, because silence is not
507 // a statement: an agent too old to send a field, or a Hello carrying no facts
508 // at all, would otherwise erase what an earlier one told us. A host with
509 // genuinely nothing to say sends a value saying so — a Mac reports virt "none",
510 // not "".
500 func (s *Store) UpdateHostFacts(id string, f HostFacts) error { 511 func (s *Store) UpdateHostFacts(id string, f HostFacts) error {
501 _, err := s.db.Exec( 512 _, err := s.db.Exec(
502 `UPDATE hosts SET os_id=?, os_pretty=?, os_version=?, kernel=?, cpu_model=?, virt=? WHERE id=?`, 513 `UPDATE hosts SET
503 f.OSID, f.OSPretty, f.OSVersion, f.Kernel, f.CPUModel, f.Virt, id) 514 os_id = CASE WHEN ?='' THEN os_id ELSE ? END,
515 os_pretty = CASE WHEN ?='' THEN os_pretty ELSE ? END,
516 os_version = CASE WHEN ?='' THEN os_version ELSE ? END,
517 kernel = CASE WHEN ?='' THEN kernel ELSE ? END,
518 cpu_model = CASE WHEN ?='' THEN cpu_model ELSE ? END,
519 virt = CASE WHEN ?='' THEN virt ELSE ? END,
520 provisioner = CASE WHEN ?='' THEN provisioner ELSE ? END
521 WHERE id=?`,
522 f.OSID, f.OSID, f.OSPretty, f.OSPretty, f.OSVersion, f.OSVersion,
523 f.Kernel, f.Kernel, f.CPUModel, f.CPUModel, f.Virt, f.Virt,
524 f.Provisioner, f.Provisioner, id)
504 return err 525 return err
505 } 526 }
506 527
internal/server/store/store_test.go
Old New
@@ -794,3 +794,53 @@ func TestUpdateHostFactsRoundTrips(t *testing.T) {
794 require.Len(t, hosts, 1) 794 require.Len(t, hosts, 1)
795 assert.Equal(t, "6.1.0-18-amd64", hosts[0].Kernel) 795 assert.Equal(t, "6.1.0-18-amd64", hosts[0].Kernel)
796 } 796 }
797
798 // TestUpdateHostFactsRefreshesProvisioner pins the fix for a host that lied
799 // about itself: the provisioner is recorded at enrollment and can change under
800 // a host, so it is refreshed from every Hello like the rest of its identity. A
801 // Mac enrolled before its backend existed reported "inert" — a backend since
802 // deleted from the tree — for as long as the row stood.
803 func TestUpdateHostFactsRefreshesProvisioner(t *testing.T) {
804 s := newStore(t)
805 h := enrollHost(t, s) // enrolls as cloudhv
806
807 require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{Provisioner: "vfkit"}))
808 got, err := s.GetHost(h.ID)
809 require.NoError(t, err)
810 assert.Equal(t, "vfkit", got.Provisioner)
811 }
812
813 // TestUpdateHostFactsEmptyNeverErases pins that silence is not a statement. An
814 // agent too old to report a field, or a Hello carrying no facts at all, must
815 // leave what an earlier agent said intact — otherwise a mixed-version fleet
816 // blanks its own host rows on every reconnect. A host with genuinely nothing to
817 // say sends a value saying so: a Mac reports virt "none", not "".
818 func TestUpdateHostFactsEmptyNeverErases(t *testing.T) {
819 s := newStore(t)
820 h := enrollHost(t, s)
821
822 full := HostFacts{
823 OSID: "ubuntu", OSPretty: "Ubuntu 26.04 LTS", OSVersion: "26.04",
824 Kernel: "7.0.0-28-generic", CPUModel: "Apple M1", Virt: "none",
825 Provisioner: "vfkit",
826 }
827 require.NoError(t, s.UpdateHostFacts(h.ID, full))
828 require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{}), "an empty report must not fail")
829
830 got, err := s.GetHost(h.ID)
831 require.NoError(t, err)
832 assert.Equal(t, full.OSID, got.OSID)
833 assert.Equal(t, full.OSPretty, got.OSPretty)
834 assert.Equal(t, full.OSVersion, got.OSVersion)
835 assert.Equal(t, full.Kernel, got.Kernel)
836 assert.Equal(t, full.CPUModel, got.CPUModel)
837 assert.Equal(t, full.Virt, got.Virt)
838 assert.Equal(t, full.Provisioner, got.Provisioner)
839
840 // A partial report updates only what it carries.
841 require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{Kernel: "7.0.1-generic"}))
842 got, err = s.GetHost(h.ID)
843 require.NoError(t, err)
844 assert.Equal(t, "7.0.1-generic", got.Kernel)
845 assert.Equal(t, "vfkit", got.Provisioner, "an unmentioned field keeps its value")
846 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -178,7 +178,7 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
178 178
179 // Best-effort: refresh the host's OS facts from this Hello. A failed write 179 // Best-effort: refresh the host's OS facts from this Hello. A failed write
180 // must not drop the connection — the report path is otherwise authoritative. 180 // must not drop the connection — the report path is otherwise authoritative.
181 if err := s.st.UpdateHostFacts(hostID, toStoreFacts(h.GetFacts())); err != nil { 181 if err := s.st.UpdateHostFacts(hostID, toStoreFacts(h)); err != nil {
182 slog.Warn("update host facts", "host", hostID, "err", err) 182 slog.Warn("update host facts", "host", hostID, "err", err)
183 } 183 }
184 184
@@ -488,14 +488,19 @@ func toRegistryCapacity(c *pb.Capacity) registry.Capacity {
488 return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()} 488 return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()}
489 } 489 }
490 490
491 // toStoreFacts maps reported host facts (nil → zero value) to the store shape. 491 // toStoreFacts maps what a Hello says about its host to the store shape. The
492 func toStoreFacts(f *pb.HostFacts) store.HostFacts { 492 // provisioner is a top-level Hello field rather than one of HostFacts, but it
493 if f == nil { 493 // is the same KIND of fact — slow-changing host identity, refreshed on every
494 return store.HostFacts{} 494 // reconnect — so it is written by the same path instead of a second one.
495 } 495 //
496 // A nil facts block yields zero values, which UpdateHostFacts reads as "said
497 // nothing" and leaves the row alone.
498 func toStoreFacts(h *pb.Hello) store.HostFacts {
499 f := h.GetFacts()
496 return store.HostFacts{ 500 return store.HostFacts{
497 OSID: f.GetOsId(), OSPretty: f.GetOsPretty(), OSVersion: f.GetOsVersion(), 501 OSID: f.GetOsId(), OSPretty: f.GetOsPretty(), OSVersion: f.GetOsVersion(),
498 Kernel: f.GetKernel(), CPUModel: f.GetCpuModel(), Virt: f.GetVirt(), 502 Kernel: f.GetKernel(), CPUModel: f.GetCpuModel(), Virt: f.GetVirt(),
503 Provisioner: h.GetProvisioner(),
499 } 504 }
500 } 505 }
501 506
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -113,6 +113,13 @@ func (c *testConn) recv(t *testing.T) *pb.ServerMessage {
113 // or an error from AcceptStream (auth rejection surfaces there). 113 // or an error from AcceptStream (auth rejection surfaces there).
114 func dial(t *testing.T, addr, fp, hostID, cred string) (*testConn, error) { 114 func dial(t *testing.T, addr, fp, hostID, cred string) (*testConn, error) {
115 t.Helper() 115 t.Helper()
116 return dialAs(t, addr, fp, hostID, cred, "cloudhv")
117 }
118
119 // dialAs is dial with the provisioner the Hello claims, so a test can watch a
120 // host change backend.
121 func dialAs(t *testing.T, addr, fp, hostID, cred, provisioner string) (*testConn, error) {
122 t.Helper()
116 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 123 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
117 defer cancel() 124 defer cancel()
118 conn, err := quic.DialAddr(ctx, addr, transport.ClientTLS(fp), 125 conn, err := quic.DialAddr(ctx, addr, transport.ClientTLS(fp),
@@ -125,7 +132,7 @@ func dial(t *testing.T, addr, fp, hostID, cred string) (*testConn, error) {
125 return nil, err 132 return nil, err
126 } 133 }
127 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ 134 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{
128 HostId: hostID, Provisioner: "cloudhv", Credential: cred}}} 135 HostId: hostID, Provisioner: provisioner, Credential: cred}}}
129 if err := transport.WriteMsg(up, hello); err != nil { 136 if err := transport.WriteMsg(up, hello); err != nil {
130 return nil, err 137 return nil, err
131 } 138 }
@@ -847,3 +854,24 @@ func TestUpgradeOffers(t *testing.T) {
847 t.Fatal("ClearAgentUpgrade must drop the offer") 854 t.Fatal("ClearAgentUpgrade must drop the offer")
848 } 855 }
849 } 856 }
857
858 // TestHelloRefreshesProvisioner pins that a host which changes backend stops
859 // lying about itself on its next reconnect. The provisioner is recorded at
860 // enrollment and was only ever LOGGED on Hello, so a Mac enrolled before its
861 // backend existed kept reporting "inert" — a backend since deleted from the
862 // tree — to the console, the CLI and anyone reading the fleet.
863 func TestHelloRefreshesProvisioner(t *testing.T) {
864 f := setup(t)
865 before, err := f.st.GetHost(f.host.ID)
866 require.NoError(t, err)
867 require.Equal(t, "cloudhv", before.Provisioner, "fixture enrolls as cloudhv")
868
869 c, err := dialAs(t, f.addr, f.fp, f.host.ID, f.cred, "vfkit")
870 require.NoError(t, err)
871 c.recv(t) // initial snapshot: the Hello has been processed
872
873 require.Eventually(t, func() bool {
874 h, err := f.st.GetHost(f.host.ID)
875 return err == nil && h.Provisioner == "vfkit"
876 }, 2*time.Second, 20*time.Millisecond, "the host row must follow what the agent reports")
877 }