b982a55a
fix(server): a guest's address is judged on its own terms, not the fleet's
a73x 2026-08-06 09:12
Commit message
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -1016,48 +1016,39 @@ func (s *Store) ForceRemoveHost(id string) (int, error) { | |||
| 1016 | return int(purged), nil | 1016 | return int(purged), nil |
| 1017 | } | 1017 | } |
| 1018 | 1018 | ||
| 1019 | // ipWithinHostCIDR reports whether ip falls inside the owning host's bridge_cidr. | 1019 | // usableGuestAddress reports whether ip is an address a guest could actually be |
| 1020 | // A missing row, an unparseable stored CIDR, or an unparseable ip all yield | 1020 | // reached on. It asks nothing about topology: a host's guests live wherever that |
| 1021 | // (false, nil) — the IP simply cannot be validated, so callers drop it. Only a | 1021 | // host's network puts them — inside a Linux bridge the fleet allocated, or on |
| 1022 | // genuine DB error on the lookup is surfaced. | 1022 | // whatever subnet macOS's vmnet happens to run — and the fleet is told that |
| 1023 | func (s *Store) ipWithinHostCIDR(vmID, ip string) (bool, error) { | 1023 | // subnet, it does not decide it. So the only thing worth rejecting here is a |
| 1024 | var cidrStr string | 1024 | // value that names no reachable guest under any topology: unparseable, the |
| 1025 | switch err := s.db.QueryRow( | 1025 | // unspecified address, loopback, link-local (the 169.254/16 a guest reports when |
| 1026 | `SELECT h.bridge_cidr FROM vms v JOIN hosts h ON h.id = v.host_id WHERE v.id=?`, vmID, | 1026 | // DHCP never answered), or multicast. |
| 1027 | ).Scan(&cidrStr); { | 1027 | func usableGuestAddress(ip string) bool { |
| 1028 | case errors.Is(err, sql.ErrNoRows): | ||
| 1029 | return false, nil | ||
| 1030 | case err != nil: | ||
| 1031 | return false, fmt.Errorf("lookup host cidr: %w", err) | ||
| 1032 | } | ||
| 1033 | prefix, err := netip.ParsePrefix(cidrStr) | ||
| 1034 | if err != nil { | ||
| 1035 | return false, nil | ||
| 1036 | } | ||
| 1037 | addr, err := netip.ParseAddr(ip) | 1028 | addr, err := netip.ParseAddr(ip) |
| 1038 | if err != nil { | 1029 | if err != nil { |
| 1039 | return false, nil | 1030 | return false |
| 1040 | } | 1031 | } |
| 1041 | return prefix.Contains(addr), nil | 1032 | return !addr.IsUnspecified() && !addr.IsLoopback() && |
| 1033 | !addr.IsLinkLocalUnicast() && !addr.IsLinkLocalMulticast() && !addr.IsMulticast() | ||
| 1042 | } | 1034 | } |
| 1043 | 1035 | ||
| 1044 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | 1036 | // RecordVMStatus persists one VM's (status, last_error, assigned_ip) and returns |
| 1045 | // A reported IP outside the owning host's bridge_cidr (a DHCP hiccup, a | 1037 | // the address it wrote — empty when it wrote none, either because the report |
| 1046 | // link-local/APIPA address, or an agent bug) must NOT block the (status, | 1038 | // carried none or because the address was unusable. |
| 1047 | // last_error) transition: a genuinely-failed VM still needs status=failed | 1039 | // |
| 1048 | // persisted durably. So an out-of-CIDR (or unparseable) IP is DROPPED — | 1040 | // An unusable address must NOT block the (status, last_error) transition: a |
| 1049 | // assigned_ip keeps its prior value via the CASE below — while the status | 1041 | // genuinely-failed VM still needs status=failed persisted durably. So it is |
| 1050 | // still writes. This preserves the integrity guarantee (a bogus IP never | 1042 | // DROPPED — assigned_ip keeps its prior value via the CASE below — while the |
| 1051 | // lands in the row) without stalling the transition or spamming per-tick | 1043 | // status still writes. |
| 1052 | // rejection warnings from syncsvc.applyReport. | 1044 | // |
| 1053 | if ip != "" { | 1045 | // The returned address is what makes that drop visible to the caller. A guard |
| 1054 | within, err := s.ipWithinHostCIDR(id, ip) | 1046 | // that silently blanks its argument and reports success is how syncsvc's status |
| 1055 | if err != nil { | 1047 | // cache came to remember addresses that were never stored, and then suppress |
| 1056 | return err | 1048 | // every later report that would have corrected them. |
| 1057 | } | 1049 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { |
| 1058 | if !within { | 1050 | if ip != "" && !usableGuestAddress(ip) { |
| 1059 | ip = "" | 1051 | ip = "" |
| 1060 | } | ||
| 1061 | } | 1052 | } |
| 1062 | 1053 | ||
| 1063 | res, err := s.db.Exec( | 1054 | res, err := s.db.Exec( |
| @@ -1065,13 +1056,13 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | |||
| 1065 | status, lastErr, ip, ip, id, | 1056 | status, lastErr, ip, ip, id, |
| 1066 | ) | 1057 | ) |
| 1067 | if err != nil { | 1058 | if err != nil { |
| 1068 | return err | 1059 | return "", err |
| 1069 | } | 1060 | } |
| 1070 | n, _ := res.RowsAffected() | 1061 | n, _ := res.RowsAffected() |
| 1071 | if n == 0 { | 1062 | if n == 0 { |
| 1072 | return sql.ErrNoRows | 1063 | return "", sql.ErrNoRows |
| 1073 | } | 1064 | } |
| 1074 | return nil | 1065 | return ip, nil |
| 1075 | } | 1066 | } |
| 1076 | 1067 | ||
| 1077 | // vmColumns is the positional column list every VM SELECT must use, so the | 1068 | // vmColumns is the positional column list every VM SELECT must use, so the |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -271,7 +271,8 @@ func TestDesiredStateMutationsBumpEpochButStatusWritesDoNot(t *testing.T) { | |||
| 271 | e2, _ := s.Epoch() | 271 | e2, _ := s.Epoch() |
| 272 | assert.Equal(t, e1+1, e2, "power edit bumps") | 272 | assert.Equal(t, e1+1, e2, "power edit bumps") |
| 273 | 273 | ||
| 274 | require.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.2")) | 274 | _, err := s.RecordVMStatus("vm1", "ready", "", "10.77.1.2") |
| 275 | require.NoError(t, err) | ||
| 275 | e3, _ := s.Epoch() | 276 | e3, _ := s.Epoch() |
| 276 | assert.Equal(t, e2, e3, "agent-reported status does NOT bump") | 277 | assert.Equal(t, e2, e3, "agent-reported status does NOT bump") |
| 277 | 278 | ||
| @@ -311,27 +312,53 @@ func TestDesiredForHostIncludesTombstonedAndEpochConsistently(t *testing.T) { | |||
| 311 | assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked") | 312 | assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked") |
| 312 | } | 313 | } |
| 313 | 314 | ||
| 314 | func TestRecordVMStatusDropsOutOfCIDRIPButKeepsStatus(t *testing.T) { | 315 | // TestRecordVMStatusKeepsReachableAddressesAndDropsUnusableOnes pins what the |
| 315 | s := newStore(t) | 316 | // address guard asks. It does not ask where a host's guests live: a Mac's guests |
| 316 | h := enrollHost(t, s) // 10.77.1.0/24 | 317 | // sit on whatever subnet vmnet runs, which no fleet allocation will ever |
| 317 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", | 318 | // contain. It asks only whether the value names a guest anything could reach. |
| 318 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | 319 | // Whatever the answer, the (status, last_error) transition still writes. |
| 319 | 320 | func TestRecordVMStatusKeepsReachableAddressesAndDropsUnusableOnes(t *testing.T) { | |
| 320 | // A good IP lands. | 321 | const prior = "10.77.1.9" |
| 321 | require.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.9")) | 322 | for _, tc := range []struct { |
| 322 | vm, err := s.GetVM("vm1") | 323 | name string |
| 323 | require.NoError(t, err) | 324 | ip string |
| 324 | assert.Equal(t, "10.77.1.9", vm.AssignedIP) | 325 | want string // the address the row should hold afterwards |
| 325 | 326 | }{ | |
| 326 | // An out-of-CIDR IP alongside a failed transition must NOT block the status | 327 | {"a guest on the host's own bridge", "10.77.1.20", "10.77.1.20"}, |
| 327 | // write, and must NOT overwrite the prior good IP with the bogus value. | 328 | {"a guest on a subnet the fleet never allocated", "192.168.64.7", "192.168.64.7"}, |
| 328 | require.NoError(t, s.RecordVMStatus("vm1", "failed", "boom", "10.77.2.9"), | 329 | {"unspecified", "0.0.0.0", prior}, |
| 329 | "out-of-CIDR IP must not fail the status write") | 330 | {"loopback", "127.0.0.1", prior}, |
| 330 | vm, err = s.GetVM("vm1") | 331 | {"link-local: DHCP never answered", "169.254.11.2", prior}, |
| 331 | require.NoError(t, err) | 332 | {"multicast", "224.0.0.1", prior}, |
| 332 | assert.Equal(t, "failed", vm.Status, "status transition must persist despite the bad IP") | 333 | {"unparseable", "not-an-ip", prior}, |
| 333 | assert.Equal(t, "boom", vm.LastError) | 334 | {"none reported", "", prior}, |
| 334 | assert.Equal(t, "10.77.1.9", vm.AssignedIP, "bogus IP must be dropped, prior kept") | 335 | } { |
| 336 | t.Run(tc.name, func(t *testing.T) { | ||
| 337 | s := newStore(t) | ||
| 338 | h := enrollHost(t, s) // 10.77.1.0/24 | ||
| 339 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", | ||
| 340 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 341 | _, err := s.RecordVMStatus("vm1", "ready", "", prior) | ||
| 342 | require.NoError(t, err) | ||
| 343 | |||
| 344 | written, err := s.RecordVMStatus("vm1", "failed", "boom", tc.ip) | ||
| 345 | require.NoError(t, err, "the address must never fail the status write") | ||
| 346 | |||
| 347 | vm, err := s.GetVM("vm1") | ||
| 348 | require.NoError(t, err) | ||
| 349 | assert.Equal(t, "failed", vm.Status, "the status transition persists regardless") | ||
| 350 | assert.Equal(t, "boom", vm.LastError) | ||
| 351 | assert.Equal(t, tc.want, vm.AssignedIP) | ||
| 352 | |||
| 353 | // What it says it wrote is what it wrote: the address on a keep, and | ||
| 354 | // nothing at all on a drop — the caller cannot tell them apart otherwise. | ||
| 355 | if tc.want == prior { | ||
| 356 | assert.Empty(t, written, "a dropped address must be reported as written-nothing") | ||
| 357 | } else { | ||
| 358 | assert.Equal(t, tc.ip, written) | ||
| 359 | } | ||
| 360 | }) | ||
| 361 | } | ||
| 335 | } | 362 | } |
| 336 | 363 | ||
| 337 | func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { | 364 | func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { |
| @@ -363,7 +390,7 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) { | |||
| 363 | 390 | ||
| 364 | func TestRecordVMStatusUnknownVMErrors(t *testing.T) { | 391 | func TestRecordVMStatusUnknownVMErrors(t *testing.T) { |
| 365 | s := newStore(t) | 392 | s := newStore(t) |
| 366 | err := s.RecordVMStatus("nope", "ready", "", "") | 393 | _, err := s.RecordVMStatus("nope", "ready", "", "") |
| 367 | assert.Error(t, err) | 394 | assert.Error(t, err) |
| 368 | } | 395 | } |
| 369 | 396 | ||
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -31,7 +31,8 @@ const defaultWriteTimeout = 30 * time.Second | |||
| 31 | // direct s.st call) lets tests substitute a counting fake to prove that | 31 | // direct s.st call) lets tests substitute a counting fake to prove that |
| 32 | // unchanged reports perform no write. | 32 | // unchanged reports perform no write. |
| 33 | type vmStatusRecorder interface { | 33 | type vmStatusRecorder interface { |
| 34 | RecordVMStatus(id, status, lastErr, ip string) error | 34 | // RecordVMStatus returns the address it wrote, empty when it wrote none. |
| 35 | RecordVMStatus(id, status, lastErr, ip string) (string, error) | ||
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | // Service is the QUIC server end of the agent reconcile stream. | 38 | // Service is the QUIC server end of the agent reconcile stream. |
| @@ -381,8 +382,9 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 381 | } | 382 | } |
| 382 | vmID := v.GetVmId() | 383 | vmID := v.GetVmId() |
| 383 | // Pass the RAW reported ip to RecordVMStatus to preserve its | 384 | // Pass the RAW reported ip to RecordVMStatus to preserve its |
| 384 | // empty-ip-keeps-prior UPDATE semantics. | 385 | // empty-ip-keeps-prior UPDATE semantics, and cache the address it |
| 385 | err := s.tracker.writeThrough(vmID, phase, v.GetLastError(), v.GetIp(), func() error { | 386 | // reports back rather than the one we sent it. |
| 387 | err := s.tracker.writeThrough(vmID, phase, v.GetLastError(), v.GetIp(), func() (string, error) { | ||
| 386 | return s.recorder.RecordVMStatus(vmID, phase, v.GetLastError(), v.GetIp()) | 388 | return s.recorder.RecordVMStatus(vmID, phase, v.GetLastError(), v.GetIp()) |
| 387 | }) | 389 | }) |
| 388 | if err != nil { | 390 | if err != nil { |
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -212,7 +212,7 @@ type countingRecorder struct { | |||
| 212 | calls int | 212 | calls int |
| 213 | } | 213 | } |
| 214 | 214 | ||
| 215 | func (c *countingRecorder) RecordVMStatus(id, status, lastErr, ip string) error { | 215 | func (c *countingRecorder) RecordVMStatus(id, status, lastErr, ip string) (string, error) { |
| 216 | c.mu.Lock() | 216 | c.mu.Lock() |
| 217 | c.calls++ | 217 | c.calls++ |
| 218 | c.mu.Unlock() | 218 | c.mu.Unlock() |
| @@ -292,6 +292,43 @@ func TestReportWithValidIPUpdatesRegistryAndStore(t *testing.T) { | |||
| 292 | }, 2*time.Second, 20*time.Millisecond) | 292 | }, 2*time.Second, 20*time.Millisecond) |
| 293 | } | 293 | } |
| 294 | 294 | ||
| 295 | // TestReportRecoversFromAnUnusableAddress walks the whole path a guest takes | ||
| 296 | // when its first answer is a bad one: an address the store refuses must not | ||
| 297 | // poison the write path against the address that follows it. A host whose guests | ||
| 298 | // live outside anything the fleet allocated — a Mac's vmnet, say — reports one | ||
| 299 | // of those on every tick, so "the second report is treated as unchanged" means | ||
| 300 | // blank forever, not blank once. | ||
| 301 | func TestReportRecoversFromAnUnusableAddress(t *testing.T) { | ||
| 302 | f := setup(t) | ||
| 303 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 304 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 305 | c := mustDial(t, f) | ||
| 306 | c.recv(t) | ||
| 307 | |||
| 308 | ready := func(ip string) *pb.AgentMessage { | ||
| 309 | return &pb.AgentMessage{Msg: &pb.AgentMessage_Report{ | ||
| 310 | Report: &pb.ActualStateReport{Vms: []*pb.ActualVM{ | ||
| 311 | {VmId: "vm1", Power: "running", Phase: "ready", Ip: ip}, | ||
| 312 | }}}} | ||
| 313 | } | ||
| 314 | |||
| 315 | // DHCP never answered: the guest reports the address it made up for itself. | ||
| 316 | c.send(t, ready("169.254.11.2")) | ||
| 317 | require.Eventually(t, func() bool { | ||
| 318 | vms, _ := f.st.ListVMs() | ||
| 319 | return len(vms) == 1 && vms[0].Status == "ready" | ||
| 320 | }, 2*time.Second, 20*time.Millisecond, "the status must write even so") | ||
| 321 | vms, _ := f.st.ListVMs() | ||
| 322 | require.Empty(t, vms[0].AssignedIP, "an unusable address must not land") | ||
| 323 | |||
| 324 | // DHCP answers, on a subnet no fleet allocation contains. | ||
| 325 | c.send(t, ready("192.168.64.7")) | ||
| 326 | require.Eventually(t, func() bool { | ||
| 327 | vms, _ := f.st.ListVMs() | ||
| 328 | return len(vms) == 1 && vms[0].AssignedIP == "192.168.64.7" | ||
| 329 | }, 2*time.Second, 20*time.Millisecond, "the address that works must land") | ||
| 330 | } | ||
| 331 | |||
| 295 | func TestHardDeleteTriggersRepush(t *testing.T) { | 332 | func TestHardDeleteTriggersRepush(t *testing.T) { |
| 296 | f := setup(t) | 333 | f := setup(t) |
| 297 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | 334 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", |
internal/server/syncsvc/tracker.go
| Old | New | ||
|---|---|---|---|
| @@ -30,32 +30,41 @@ func newStatusTracker() *statusTracker { | |||
| 30 | // disagreeing with the row — a divergence that would then suppress every later | 30 | // disagreeing with the row — a divergence that would then suppress every later |
| 31 | // identical report indefinitely. | 31 | // identical report indefinitely. |
| 32 | // | 32 | // |
| 33 | // write performs the durable RecordVMStatus call; it runs ONLY when the durable | 33 | // write performs the durable RecordVMStatus call and RETURNS THE ADDRESS IT |
| 34 | // triple (status, last_error, effective assigned_ip) changed, and the cache is | 34 | // WROTE; it runs ONLY when the report could still change the row, and the cache |
| 35 | // updated ONLY if write returns nil, so a rejected write never suppresses the | 35 | // is updated ONLY if write returns nil, so a rejected write never suppresses the |
| 36 | // next retry. write receives no arguments: the caller passes the RAW reported ip | 36 | // next retry. write receives no arguments: the caller passes the RAW reported ip |
| 37 | // to RecordVMStatus (whose UPDATE keeps the prior ip on empty), while the cache | 37 | // to RecordVMStatus (whose UPDATE keeps the prior ip on empty). |
| 38 | // stores the effective ip computed here. | ||
| 39 | // | 38 | // |
| 40 | // The effective ip mirrors RecordVMStatus's UPDATE SET clause | 39 | // The tracker never decides what an address means. It caches what the write says |
| 41 | // `assigned_ip = CASE WHEN ?=” THEN assigned_ip ELSE ? END`: an EMPTY reported | 40 | // it stored, folded with that UPDATE's own empty-keeps-prior rule |
| 42 | // ip keeps the prior stored ip (prior.ip=="" when there is no prior entry); a | 41 | // (`assigned_ip = CASE WHEN ?='' THEN assigned_ip ELSE ? END`), so the cache |
| 43 | // non-empty ip replaces it. | 42 | // holds what the row holds. An address the store DROPPED therefore leaves the |
| 44 | func (t *statusTracker) writeThrough(vmID, status, lastErr, ip string, write func() error) error { | 43 | // cache on the prior value rather than the rejected one, and the next report |
| 44 | // carrying a good address reads as a change and lands. Deciding the effective ip | ||
| 45 | // here instead — from the value the agent REPORTED rather than the one the store | ||
| 46 | // WROTE — is how a permanently blank assigned_ip survived every report that | ||
| 47 | // would have filled it. | ||
| 48 | func (t *statusTracker) writeThrough(vmID, status, lastErr, ip string, write func() (string, error)) error { | ||
| 45 | t.mu.Lock() | 49 | t.mu.Lock() |
| 46 | defer t.mu.Unlock() | 50 | defer t.mu.Unlock() |
| 47 | prior, ok := t.last[vmID] | 51 | prior, ok := t.last[vmID] |
| 48 | effIP := ip | 52 | // Decide on the RAW ip. Whether the store keeps it is the store's business; |
| 49 | if effIP == "" { | 53 | // all this needs is that a SKIPPED report is one that provably changes |
| 50 | effIP = prior.ip | 54 | // nothing — an empty ip leaves assigned_ip alone, and an ip already stored |
| 51 | } | 55 | // rewrites it to itself. |
| 52 | if ok && prior.status == status && prior.lastErr == lastErr && prior.ip == effIP { | 56 | if ok && prior.status == status && prior.lastErr == lastErr && |
| 57 | (ip == "" || ip == prior.ip) { | ||
| 53 | return nil // unchanged — skip the write | 58 | return nil // unchanged — skip the write |
| 54 | } | 59 | } |
| 55 | if err := write(); err != nil { | 60 | written, err := write() |
| 61 | if err != nil { | ||
| 56 | return err // rejected — do not cache; let the next report retry | 62 | return err // rejected — do not cache; let the next report retry |
| 57 | } | 63 | } |
| 58 | t.last[vmID] = vmStatus{status: status, lastErr: lastErr, ip: effIP} | 64 | if written == "" { |
| 65 | written = prior.ip | ||
| 66 | } | ||
| 67 | t.last[vmID] = vmStatus{status: status, lastErr: lastErr, ip: written} | ||
| 59 | return nil | 68 | return nil |
| 60 | } | 69 | } |
| 61 | 70 | ||
internal/server/syncsvc/tracker_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,11 +8,12 @@ import ( | |||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // write is a tiny helper that runs writeThrough with a SUCCESSFUL durable write, | 11 | // write is a tiny helper that runs writeThrough with a SUCCESSFUL durable write |
| 12 | // returning whether a write actually happened (the triple changed). | 12 | // that STORES the address it was given, returning whether a write actually |
| 13 | // happened (the report could still change the row). | ||
| 13 | func (t *statusTracker) write(vmID, status, lastErr, ip string) bool { | 14 | func (t *statusTracker) write(vmID, status, lastErr, ip string) bool { |
| 14 | wrote := false | 15 | wrote := false |
| 15 | _ = t.writeThrough(vmID, status, lastErr, ip, func() error { wrote = true; return nil }) | 16 | _ = t.writeThrough(vmID, status, lastErr, ip, func() (string, error) { wrote = true; return ip, nil }) |
| 16 | return wrote | 17 | return wrote |
| 17 | } | 18 | } |
| 18 | 19 | ||
| @@ -71,13 +72,18 @@ func TestStatusTrackerFailedWriteNotCached(t *testing.T) { | |||
| 71 | 72 | ||
| 72 | // The durable write FAILS: writeThrough must surface the error and NOT cache. | 73 | // The durable write FAILS: writeThrough must surface the error and NOT cache. |
| 73 | sentinel := errors.New("record rejected") | 74 | sentinel := errors.New("record rejected") |
| 74 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() error { return sentinel }); err != sentinel { | 75 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() (string, error) { |
| 76 | return "", sentinel | ||
| 77 | }); err != sentinel { | ||
| 75 | t.Fatalf("failed write must surface its error, got %v", err) | 78 | t.Fatalf("failed write must surface its error, got %v", err) |
| 76 | } | 79 | } |
| 77 | // Because the write failed (uncached), the next report must still attempt it — | 80 | // Because the write failed (uncached), the next report must still attempt it — |
| 78 | // a failed write must never suppress the retry. | 81 | // a failed write must never suppress the retry. |
| 79 | attempted := false | 82 | attempted := false |
| 80 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() error { attempted = true; return nil }); err != nil { | 83 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() (string, error) { |
| 84 | attempted = true | ||
| 85 | return "10.0.0.1", nil | ||
| 86 | }); err != nil { | ||
| 81 | t.Fatalf("retry write errored: %v", err) | 87 | t.Fatalf("retry write errored: %v", err) |
| 82 | } | 88 | } |
| 83 | if !attempted { | 89 | if !attempted { |
| @@ -89,6 +95,43 @@ func TestStatusTrackerFailedWriteNotCached(t *testing.T) { | |||
| 89 | } | 95 | } |
| 90 | } | 96 | } |
| 91 | 97 | ||
| 98 | // TestStatusTrackerDroppedAddressNotCached pins the half of the invariant that a | ||
| 99 | // silent guard broke: the cache holds what the STORE says it stored, never what | ||
| 100 | // the agent merely reported. A store that drops an address says so by returning | ||
| 101 | // nothing, and the tracker must then leave the cache where the row is — because | ||
| 102 | // a cache that remembers a rejected address treats every later report of it as | ||
| 103 | // "unchanged" and suppresses the write forever, which is how a guest could run | ||
| 104 | // for days with a blank assigned_ip while its agent reported the address on | ||
| 105 | // every single tick. | ||
| 106 | func TestStatusTrackerDroppedAddressNotCached(t *testing.T) { | ||
| 107 | tr := newStatusTracker() | ||
| 108 | dropped := func() (string, error) { return "", nil } // the store rejected it | ||
| 109 | |||
| 110 | require.NoError(t, tr.writeThrough("vm1", "ready", "", "169.254.11.2", dropped)) | ||
| 111 | tr.mu.Lock() | ||
| 112 | cached := tr.last["vm1"].ip | ||
| 113 | tr.mu.Unlock() | ||
| 114 | require.Empty(t, cached, "a rejected address must not be cached as stored") | ||
| 115 | |||
| 116 | // The same rejected address again still reaches the store: nothing has been | ||
| 117 | // written, so there is nothing to skip. | ||
| 118 | attempted := false | ||
| 119 | require.NoError(t, tr.writeThrough("vm1", "ready", "", "169.254.11.2", func() (string, error) { | ||
| 120 | attempted = true | ||
| 121 | return "", nil | ||
| 122 | })) | ||
| 123 | require.True(t, attempted, "a repeat of a rejected address must still attempt the write") | ||
| 124 | |||
| 125 | // And the address that finally works lands, rather than being mistaken for a | ||
| 126 | // repeat of something already stored. | ||
| 127 | if !tr.write("vm1", "ready", "", "10.0.0.5") { | ||
| 128 | t.Fatal("a good address after a rejected one must write") | ||
| 129 | } | ||
| 130 | if tr.write("vm1", "ready", "", "10.0.0.5") { | ||
| 131 | t.Fatal("once stored, the same address must not write again") | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 92 | // TestStatusTrackerWriteThroughAtomic pins the invariant Fix D restores: under | 135 | // TestStatusTrackerWriteThroughAtomic pins the invariant Fix D restores: under |
| 93 | // concurrent conflicting reports for one VM, the cached triple always equals the | 136 | // concurrent conflicting reports for one VM, the cached triple always equals the |
| 94 | // LAST durable write. writeThrough holds the lock across write+commit, so the | 137 | // LAST durable write. writeThrough holds the lock across write+commit, so the |
| @@ -101,11 +144,11 @@ func TestStatusTrackerWriteThroughAtomic(t *testing.T) { | |||
| 101 | lastWritten := "" // status of the most recent successful durable write | 144 | lastWritten := "" // status of the most recent successful durable write |
| 102 | 145 | ||
| 103 | writer := func(status string) { | 146 | writer := func(status string) { |
| 104 | _ = tr.writeThrough("vm1", status, "", "10.0.0.1", func() error { | 147 | _ = tr.writeThrough("vm1", status, "", "10.0.0.1", func() (string, error) { |
| 105 | mu.Lock() | 148 | mu.Lock() |
| 106 | lastWritten = status // recorded inside writeThrough's critical section | 149 | lastWritten = status // recorded inside writeThrough's critical section |
| 107 | mu.Unlock() | 150 | mu.Unlock() |
| 108 | return nil | 151 | return "10.0.0.1", nil |
| 109 | }) | 152 | }) |
| 110 | } | 153 | } |
| 111 | 154 | ||