63a6dec6
fix(server): a host keeps saying what it is
a73x 2026-08-06 09:12
Commit message
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 | } | ||