From 84facf27bbdd39cf2bc69e06bd3b16df104cc381 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:17:27 +0100 Subject: [PATCH] Avoid Linux-only memory reads on observed Windows guests Bound Windows memory-read suppression to an actual same-guest OS reply and its original age. Preserve unknown/Linux compatibility, independent readings and every-command backup locks; retain accepted OS admission evidence across later metadata deferral without publishing partial identity. Exercise real QGA-only polling, History and recovery alongside source-age and cache controls. Change-source: pulse-maintainer --- .../v6/internal/subsystems/monitoring.md | 29 +++ .../monitoring/canonical_guardrails_test.go | 4 + internal/monitoring/guest_memory_agent.go | 25 +++ internal/monitoring/guest_metadata.go | 20 +- .../guest_metadata_suppression_test.go | 6 + .../monitoring/guest_windows_memory_test.go | 175 +++++++++++++++++ .../monitoring/guest_windows_polling_test.go | 185 ++++++++++++++++++ 7 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 internal/monitoring/guest_windows_memory_test.go create mode 100644 internal/monitoring/guest_windows_polling_test.go diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 9c9c141c28..c70dd6a886 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,35 @@ ## Purpose +### Known Windows guests do not receive Linux-only memory reads + +An accepted guest OS reply identifying Windows suppresses the optional +`/proc/meminfo` fallback for that instance/node/VM, including after an earlier +failure cache expires. The classification has its own original OS observation +time, bounded by the existing ten-minute guest evidence age; other useful +network/version replies and retained display strings cannot renew it. Missing, +future and expired origins do not suppress the existing fallback. A completed +empty OS reply invalidates the classification; a subsequent Linux reply permits +ordinary memory polling, including measured zero availability. An accepted OS +outcome survives a later version-command deferral without publishing partial +identity or renewing general QGA availability evidence. + +This avoids an inapplicable command that can start a shared uncertainty pause. +It does not invent a negative memory observation or clear an existing pause. +Independent status/balloon/linked-agent memory, filesystem-first ordering, +unknown-OS compatibility and all command/backup-lock admission remain intact. +The first unknown-OS poll can still attempt the existing Linux fallback; this +change does not add a probe or infer an OS from a display name/configuration hint. + +`TestGuestWindowsMeminfoPollingContract` covers the real direct/cluster clients +and both collectors after an actual OS read: repeated NTFS usage through zero, +canonical/public read state, in-memory and persistent History, live CPU/memory, +unchanged original caches, backup-lock deferral and unlocked resumption. Unit +controls pin exact OS-name boundaries, original age/identity, partial-fetch +evidence, Linux recovery and cache/source compatibility. These are synthetic +source controls, not native QEMU-only Windows recovery, thaw, cross-process or +restart containment, reporter resolution or release acceptance. + ### QGA fallback eligibility keeps its original observation age A successful unlocked VM-status availability, usable filesystem reading or diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index cb46cbecef..8142dbe0f8 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -3166,6 +3166,10 @@ func TestGuestAgentOptionalReadOrderingContract(t *testing.T) { t.Run("recent-evidence-without-status", func(t *testing.T) { testGuestAgentOptionalReadOrdering(t, true) }) } +func TestGuestWindowsMeminfoPollingContract(t *testing.T) { + testGuestWindowsMeminfoPolling(t) +} + func TestGuestMemoryObservationContract(t *testing.T) { t.Run("poll-to-served-observation", testGuestMemoryObservationLifecycle) t.Run("identity-and-origin-boundaries", testGuestMemoryObservationKeepsOriginsSeparate) diff --git a/internal/monitoring/guest_memory_agent.go b/internal/monitoring/guest_memory_agent.go index f237c9e465..e3f75f8a0d 100644 --- a/internal/monitoring/guest_memory_agent.go +++ b/internal/monitoring/guest_memory_agent.go @@ -83,6 +83,13 @@ func (m *Monitor) getVMAgentMemoryAvailability(ctx context.Context, client PVECl cacheKey := guestMemoryCacheKey(instanceName, node, vmid) now := time.Now() + if m.guestWindowsMeminfoUnsupported(instanceName, node, vmid, now) { + // /proc/meminfo is Linux-specific. Do not knowingly queue a Windows + // file-read that can pause the shared QGA channel on an uncertain reply. + // This is neither a failed observation nor a coordination deferral; + // independent status/balloon/linked-agent memory remains usable. + return proxmox.LinuxMemoryAvailability{}, fmt.Errorf("guest agent Linux meminfo unavailable for Windows") + } m.rrdCacheMu.RLock() if entry, ok := m.vmAgentMemCache[cacheKey]; ok { @@ -155,6 +162,24 @@ func (m *Monitor) getVMAgentMemoryAvailability(ctx context.Context, client PVECl return info, nil } +func guestOSIsWindows(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return name == "mswindows" || name == "windows" || name == "microsoft windows" || + strings.HasPrefix(name, "windows ") || strings.HasPrefix(name, "microsoft windows ") +} + +func (m *Monitor) guestWindowsMeminfoUnsupported(instanceName, node string, vmid int, now time.Time) bool { + key := guestMetadataCacheKey(instanceName, node, vmid) + m.guestMetadataMu.RLock() + entry := m.guestMetadataCache[key] + m.guestMetadataMu.RUnlock() + // Use the existing ten-minute guest evidence age, not the metadata cache's + // last useful network/version receipt. Missing/future origins and copied + // display strings cannot establish the current guest's OS. + return entry.windowsGuest && !entry.osInfoObservedAt.IsZero() && !entry.osInfoObservedAt.After(now) && + now.Sub(entry.osInfoObservedAt) < vmAgentMemCleanupMaxAge +} + func (m *Monitor) vmAgentMemNegativeCacheTTL(instanceName, node string, vmid int) time.Duration { if m == nil { return vmAgentMemNegativeTTL diff --git a/internal/monitoring/guest_metadata.go b/internal/monitoring/guest_metadata.go index 7b8dcc3a7c..70b4ae2847 100644 --- a/internal/monitoring/guest_metadata.go +++ b/internal/monitoring/guest_metadata.go @@ -44,6 +44,8 @@ type guestMetadataCacheEntry struct { osVersion string agentVersion string fetchedAt time.Time // Last accepted useful metadata, not the last attempt. + windowsGuest bool // An actual OS reply, not retained display identity. + osInfoObservedAt time.Time // Network/version refreshes cannot renew OS evidence. osInfoFailureCount int // Track consecutive OS info failures osInfoSkip bool // Skip OS info calls after repeated failures (refs #692) } @@ -353,6 +355,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI // Skip OS info calls if we've seen repeated failures (refs #692 - OpenBSD qemu-ga issue) osInfoFailureCount := cached.osInfoFailureCount osInfoSkip := cached.osInfoSkip + windowsGuest, osInfoObservedAt := cached.windowsGuest, cached.osInfoObservedAt if !osInfoSkip { agentInfoRaw, err := m.retryGuestAgentCall(ctx, m.guestAgentOSInfoTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) { @@ -394,15 +397,23 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI } } else if agentInfo, ok := agentInfoRaw.(map[string]interface{}); ok && len(agentInfo) > 0 { extractedOSName, extractedOSVersion := extractGuestOSInfo(agentInfo) + windowsGuest, osInfoObservedAt = false, time.Time{} if extractedOSName != "" || extractedOSVersion != "" { osName, osVersion = extractedOSName, extractedOSVersion metadataObserved = true + windowsGuest = guestOSIsWindows(extractedOSName) + osInfoObservedAt = time.Now() } osInfoFailureCount = 0 // Reset on success osInfoSkip = false - } else if cached.osName == "" && cached.osVersion == "" { - osName = "" - osVersion = "" + } else { + // A complete empty reply is not a new observation of retained OS + // strings. Keep those strings for display, not meminfo admission. + windowsGuest, osInfoObservedAt = false, time.Time{} + if cached.osName == "" && cached.osVersion == "" { + osName = "" + osVersion = "" + } } } else { // Skipping OS info call due to repeated failures @@ -428,6 +439,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI entry := m.guestMetadataCache[key] entry.osInfoFailureCount = osInfoFailureCount entry.osInfoSkip = osInfoSkip + entry.windowsGuest, entry.osInfoObservedAt = windowsGuest, osInfoObservedAt m.guestMetadataCache[key] = entry m.guestMetadataMu.Unlock() m.deferGuestMetadataRetry(key, time.Now()) @@ -461,6 +473,8 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI osVersion: osVersion, agentVersion: agentVersion, fetchedAt: fetchedAt, + windowsGuest: windowsGuest, + osInfoObservedAt: osInfoObservedAt, osInfoFailureCount: osInfoFailureCount, osInfoSkip: osInfoSkip, } diff --git a/internal/monitoring/guest_metadata_suppression_test.go b/internal/monitoring/guest_metadata_suppression_test.go index d6f960e5c9..7ed984cef6 100644 --- a/internal/monitoring/guest_metadata_suppression_test.go +++ b/internal/monitoring/guest_metadata_suppression_test.go @@ -79,6 +79,12 @@ func TestGuestMetadataOSInfoStateSurvivesVersionDeferral(t *testing.T) { if mode == "repeated-failure" { expected.osInfoFailureCount, expected.osInfoSkip = round, round >= guestAgentOSInfoFailureThreshold } + if mode == "supported-reset" { + if entry.osInfoObservedAt.Before(start) || entry.osInfoObservedAt.After(end) || entry.windowsGuest { + t.Error("completed non-Windows OS reply lost its independent origin") + } + expected.osInfoObservedAt = entry.osInfoObservedAt + } if !reflect.DeepEqual(entry, expected) { t.Errorf("completed OS-info state lost or partial identity published: failures=%d skip=%t, want failures=%d skip=%t", entry.osInfoFailureCount, entry.osInfoSkip, expected.osInfoFailureCount, expected.osInfoSkip) } diff --git a/internal/monitoring/guest_windows_memory_test.go b/internal/monitoring/guest_windows_memory_test.go new file mode 100644 index 0000000000..366b77ebdd --- /dev/null +++ b/internal/monitoring/guest_windows_memory_test.go @@ -0,0 +1,175 @@ +package monitoring + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func TestGuestOSWindowsNameBoundary(t *testing.T) { + t.Parallel() + for _, name := range []string{"Microsoft Windows", "Microsoft Windows Server 2022", "Windows", " Windows 11 ", "MSWINDOWS"} { + if !guestOSIsWindows(name) { + t.Errorf("known Windows name %q not recognised", name) + } + } + for _, name := range []string{"", "Linux", "Ubuntu", "FreeBSD", "Android", "Windowsill Linux", "Linux with Windows tools", "my-mswindows", "Microsoft Windowsish"} { + if guestOSIsWindows(name) { + t.Errorf("unrelated OS name %q suppressed meminfo", name) + } + } +} + +func TestGuestWindowsMeminfoEvidenceBoundary(t *testing.T) { + t.Parallel() + now := time.Now() + key := guestMetadataCacheKey("pve", "node", 105) + for _, tc := range []struct { + name string + entry guestMetadataCacheEntry + want bool + }{ + {"current", guestMetadataCacheEntry{windowsGuest: true, osInfoObservedAt: now}, true}, + {"just-inside", guestMetadataCacheEntry{windowsGuest: true, osInfoObservedAt: now.Add(-vmAgentMemCleanupMaxAge + time.Nanosecond)}, true}, + {"expired", guestMetadataCacheEntry{windowsGuest: true, osInfoObservedAt: now.Add(-vmAgentMemCleanupMaxAge)}, false}, + {"future", guestMetadataCacheEntry{windowsGuest: true, osInfoObservedAt: now.Add(time.Nanosecond)}, false}, + {"missing-origin", guestMetadataCacheEntry{windowsGuest: true, fetchedAt: now}, false}, + {"display-only", guestMetadataCacheEntry{osName: "Microsoft Windows", fetchedAt: now}, false}, + {"renewed-network", guestMetadataCacheEntry{windowsGuest: true, osInfoObservedAt: now.Add(-2 * vmAgentMemCleanupMaxAge), fetchedAt: now}, false}, + {"linux", guestMetadataCacheEntry{osName: "Linux", osInfoObservedAt: now}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{key: tc.entry}} + if got := m.guestWindowsMeminfoUnsupported("pve", "node", 105, now); got != tc.want { + t.Fatalf("OS eligibility = %t, want %t", got, tc.want) + } + for _, identity := range []struct { + instance, node string + id int + }{{"other", "node", 105}, {"pve", "other", 105}, {"pve", "node", 106}} { + if m.guestWindowsMeminfoUnsupported(identity.instance, identity.node, identity.id, now) { + t.Fatal("Windows OS evidence crossed guest identity") + } + } + if !reflect.DeepEqual(m.guestMetadataCache[key], tc.entry) { + t.Fatal("eligibility read mutated OS evidence") + } + }) + } +} + +func TestGuestWindowsMeminfoSkipsWithoutChangingMemoryCache(t *testing.T) { + t.Parallel() + for _, kind := range []string{"missing", "negative", "positive"} { + t.Run(kind, func(t *testing.T) { + now := time.Now() + m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{ + guestMetadataCacheKey("pve", "node", 105): {windowsGuest: true, osInfoObservedAt: now}, + }} + key := guestMemoryCacheKey("pve", "node", 105) + entry := agentMemCacheEntry{negative: true, fetchedAt: now.Add(-2 * vmAgentMemNegativeTTL)} + if kind == "positive" { + entry = agentMemCacheEntry{info: proxmox.LinuxMemoryAvailability{Source: "meminfo-available", EffectiveAvailable: 4096}, fetchedAt: now} + } + if kind != "missing" { + m.vmAgentMemCache = map[string]agentMemCacheEntry{key: entry} + } + client := &guestMemoryAgentTestClient{stubPVEClient: &stubPVEClient{}, memAvailable: 4096} + for i := 0; i < 3; i++ { + info, err := m.getVMAgentMemoryAvailability(context.Background(), client, "pve", "node", 105) + if err == nil || errors.Is(err, proxmox.ErrGuestAgentDeferred) || info != (proxmox.LinuxMemoryAvailability{}) || client.memCalls != 0 { + t.Fatalf("known Windows queued or served Linux memory: %+v, %v; calls=%d", info, err, client.memCalls) + } + } + if got, ok := m.vmAgentMemCache[key]; (kind == "missing" && ok) || (kind != "missing" && (!ok || !reflect.DeepEqual(got, entry))) { + t.Fatal("unsupported OS invented a failure or renewed old memory") + } + }) + } +} + +func TestGuestWindowsMeminfoDoesNotSuppressOtherMemorySources(t *testing.T) { + t.Parallel() + const mib = uint64(1024 * 1024) + for _, direct := range []bool{false, true} { + m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{ + guestMetadataCacheKey("pve", "node", 105): {windowsGuest: true, osInfoObservedAt: time.Now()}, + }} + client := &guestMemoryAgentTestClient{stubPVEClient: &stubPVEClient{}, memAvailable: 4096} + status := &proxmox.VMStatus{MaxMem: 8 * mib, Mem: 3 * mib, Agent: proxmox.VMAgentField{Value: 1}} + wantSource, wantUsed := "status-mem", 3*mib + if direct { + status.MemInfo = &proxmox.VMMemInfo{Total: 8 * mib, Available: 6 * mib} + wantSource, wantUsed = "available-field", 2*mib + } + total, used, source, deferred := m.resolveGuestStatusMemory(context.Background(), client, "pve", "windows", "node", 105, "pve:node:105", status, nil, 8*mib, "", &VMMemoryRaw{}) + if total != 8*mib || used != wantUsed || source != wantSource || deferred || client.memCalls != 0 { + t.Fatalf("independent memory changed: total=%d used=%d source=%s deferred=%t calls=%d", total, used, source, deferred, client.memCalls) + } + } +} + +func TestGuestWindowsOSEvidenceKeepsActualOrigin(t *testing.T) { + t.Parallel() + for _, versionDeferred := range []bool{false, true} { + t.Run(map[bool]string{false: "complete", true: "later-deferral"}[versionDeferred], func(t *testing.T) { + m := &Monitor{guestMetadataLimiter: make(map[string]time.Time)} + key := guestMetadataCacheKey("pve", "node", 105) + client := &metadataSuppressionClient{os: map[string]interface{}{"result": map[string]interface{}{"id": "mswindows"}}} + if versionDeferred { + client.versionErr = proxmox.ErrGuestAgentDeferred + } + status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}} + fetch := func() { + m.guestMetadataLimiter[key] = time.Now().Add(-time.Second) + m.fetchGuestAgentMetadata(context.Background(), client, "pve", "node", "guest", 105, status, false) + } + before := time.Now() + fetch() + entry := m.guestMetadataCache[key] + if !entry.windowsGuest || entry.osInfoObservedAt.Before(before) || entry.osInfoObservedAt.After(time.Now()) { + t.Fatal("accepted OS reply lost its actual observation") + } + if versionDeferred && (!entry.fetchedAt.IsZero() || entry.osName != "" || m.hasRecentGuestMetadataEvidence("pve", "node", 105, time.Now())) { + t.Fatal("later deferral published partial display/availability evidence") + } + // Other useful metadata can renew display eligibility, not this OS + // origin. Force the actual refresh boundary without sleeping. + entry.fetchedAt = time.Now().Add(-2 * guestMetadataCacheTTL) + m.guestMetadataCache[key] = entry + client.osErr, client.versionErr = errors.New("OS unavailable"), nil + fetch() + refreshed := m.guestMetadataCache[key] + if !refreshed.windowsGuest || !refreshed.osInfoObservedAt.Equal(entry.osInfoObservedAt) || !refreshed.fetchedAt.After(entry.fetchedAt) { + t.Fatal("version refresh changed the independent OS origin") + } + // A complete empty OS reply invalidates the classification even + // when its old name is retained for display. + refreshed.fetchedAt = entry.fetchedAt + m.guestMetadataCache[key] = refreshed + client.os, client.osErr = nil, nil + fetch() + if got := m.guestMetadataCache[key]; got.windowsGuest || !got.osInfoObservedAt.IsZero() { + t.Fatal("empty OS response renewed retained Windows classification") + } + // A normal subsequent Linux observation restores the existing + // fallback, including a successful measured zero available value. + current := m.guestMetadataCache[key] + current.fetchedAt = entry.fetchedAt + m.guestMetadataCache[key] = current + client.os = map[string]interface{}{"name": "Linux"} + fetch() + if got := m.guestMetadataCache[key]; got.windowsGuest || got.osInfoObservedAt.IsZero() { + t.Fatal("Linux recovery retained Windows classification") + } + memClient := &guestMemoryAgentTestClient{stubPVEClient: &stubPVEClient{}, memInfo: &proxmox.LinuxMemoryAvailability{Total: 8192, Source: "meminfo-available"}} + if info, err := m.getVMAgentMemoryAvailability(context.Background(), memClient, "pve", "node", 105); err != nil || info.Source != "meminfo-available" || info.EffectiveAvailable != 0 || memClient.memCalls != 1 { + t.Fatalf("Linux zero-available fallback did not recover: %+v, %v", info, err) + } + }) + } +} diff --git a/internal/monitoring/guest_windows_polling_test.go b/internal/monitoring/guest_windows_polling_test.go new file mode 100644 index 0000000000..cfc2d7eb15 --- /dev/null +++ b/internal/monitoring/guest_windows_polling_test.go @@ -0,0 +1,185 @@ +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +// Synthetic, ordinary QGA-only polling after a successful Windows OS reply. +// The deliberately uncertain Linux read is an adverse fixture, not a replay of +// issue #2619 or a claim about the response/cause on its native installation. +func testGuestWindowsMeminfoPolling(t *testing.T) { + for _, kind := range []string{"client", "cluster-client"} { + for _, path := range []string{"cluster", "node"} { + t.Run(kind+"/"+path, func(t *testing.T) { + const mib = uint64(1024 * 1024) + var poll, fileReads, fsReads, configReads atomic.Int32 + var locked atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "PVEAPIToken=fixture@pve!pulse=fixture" { + t.Error("lost configured authentication") + w.WriteHeader(http.StatusForbidden) + return + } + switch { + case strings.HasSuffix(r.URL.Path, "/nodes"): + fmt.Fprint(w, `{"data":[]}`) + case strings.HasSuffix(r.URL.Path, "/config"): + configReads.Add(1) + if locked.Load() { + fmt.Fprint(w, `{"data":{"lock":"backup"}}`) + } else { + fmt.Fprint(w, `{"data":{}}`) + } + case strings.HasSuffix(r.URL.Path, "/status/current"): + fmt.Fprintf(w, `{"data":{"status":"running","agent":1,"maxmem":%d,"mem":%d}}`, 8*mib, uint64(2+poll.Load())*mib) + case strings.HasSuffix(r.URL.Path, "/agent/get-fsinfo"): + fsReads.Add(1) + used := uint64(200+100*poll.Load()) * mib + if poll.Load() == 3 { + used = 0 + } + fmt.Fprintf(w, `{"data":{"result":[{"mountpoint":"C:\\","type":"ntfs","total-bytes":%d,"used-bytes":%d}]}}`, 1000*mib, used) + case strings.HasSuffix(r.URL.Path, "/agent/file-read"): + fileReads.Add(1) + if r.URL.Query().Get("file") != "/proc/meminfo" { + t.Error("unexpected guest file") + } + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "optional Linux file-read failed") + case strings.HasSuffix(r.URL.Path, "/agent/network-get-interfaces"): + fmt.Fprint(w, `{"data":{"result":[{"name":"Ethernet","ip-addresses":[{"ip-address":"192.0.2.10","ip-address-type":"ipv4","prefix":24}]}]}}`) + case strings.HasSuffix(r.URL.Path, "/agent/get-osinfo"): + fmt.Fprint(w, `{"data":{"name":"Microsoft Windows Server 2022","id":"mswindows"}}`) + case strings.HasSuffix(r.URL.Path, "/agent/info"): + fmt.Fprint(w, `{"data":{"result":{"version":"fixture"}}}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + cfg := proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second} + var client PVEClientInterface + if kind == "cluster-client" { + client = proxmox.NewClusterClient("windows", cfg, []string{server.URL}, nil) + } else { + var err error + client, err = proxmox.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + } + m := guestHistoryObservationMonitor(t) + m.config, m.state, m.rateTracker = &config.Config{}, models.NewState(), NewRateTracker() + m.guestMetadataLimiter = make(map[string]time.Time) + registry := unifiedresources.NewRegistry(nil) + m.resourceStore = unifiedresources.NewMonitorAdapter(registry) + status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}} + // Learn from the real, unlocked OS endpoint, not seeded display + // strings or a configured Proxmox OS hint. No Pulse guest agent. + _, _, name, _, _, deferred := m.fetchGuestAgentMetadata(context.Background(), client, "windows", "node", "guest", 105, status, false) + if name != "Microsoft Windows Server 2022" || deferred || fileReads.Load() != 0 { + t.Fatal("normal OS observation absent") + } + metadataKey := guestMetadataCacheKey("windows", "node", 105) + originalMetadata := m.guestMetadataCache[metadataKey] + memoryKey := guestMemoryCacheKey("windows", "node", 105) + // The existing Windows failure backoff has expired: it cannot + // hide the exact parent's inappropriate read in this control. + originalMemory := agentMemCacheEntry{negative: true, fetchedAt: time.Now().Add(-2 * vmAgentMemNegativeTTL)} + m.vmAgentMemCache = map[string]agentMemCacheEntry{memoryKey: originalMemory} + id := makeGuestID("windows", "node", 105) + res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 8 * mib, MaxDisk: 1000 * mib} + build := func() models.VM { + previous := m.previousGuestContextForInstance("windows").vmsByID[id] + var vm models.VM + if path == "node" { + vms, _ := m.pollNodeVMsWithClusterResourceBuilder(context.Background(), "windows", "node", []proxmox.VM{{VMID: 105, Name: res.Name, Status: res.Status, MaxMem: res.MaxMem, MaxDisk: res.MaxDisk, CPU: res.CPU}}, client, map[string]models.VM{id: previous}, nil) + if len(vms) != 1 { + t.Fatal("node collector lost the guest") + } + vm = vms[0] + } else { + var ok bool + vm, _, _, _, _, ok = m.buildVMFromClusterResource(context.Background(), "windows", res, client, id, nil, &previous) + if !ok { + t.Fatal("cluster builder lost the guest") + } + } + m.state.UpdateVMs([]models.VM{vm}) + m.updateResourceStore(m.currentStateWithScope()) + m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second)) + view := m.currentModeReadState().VMs()[0] + if view.DiskUsed() != vm.Disk.Used || view.DiskStatusReason() != vm.DiskStatusReason || view.OSName() != vm.OSName { + t.Error("accepted Windows poll changed at canonical read boundary") + } + front := m.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{VMs: []models.VM{vm}}, m.mockModeFence.begin()) + wire, err := json.Marshal(front.Resources) + if err != nil || strings.Contains(string(wire), "windowsGuest") || strings.Contains(string(wire), "osInfoObservedAt") { + t.Fatal("private OS admission evidence leaked into JSON") + } + var served []struct{ Proxmox unifiedresources.ProxmoxData } + if err := json.Unmarshal(wire, &served); err != nil || len(served) != 1 || served[0].Proxmox.GuestAgentStatus != vm.GuestAgentStatus || served[0].Proxmox.DiskStatusReason != vm.DiskStatusReason { + t.Fatal("served Windows reading lost its status") + } + return vm + } + var last models.VM + for i := int32(1); i <= 3; i++ { + if i > 1 { + // Persistent History uses second-resolution keys; separate + // ordinary observations rather than accepting overwritten points. + time.Sleep(1100 * time.Millisecond) + } + poll.Store(i) + res.CPU = float64(i) / 10 + last = build() + wantUsed := int64(200+100*i) * int64(mib) + if i == 3 { + wantUsed = 0 + } + if last.ID != id || last.Disk.Used != wantUsed || last.DiskStatusReason != "" || last.GuestAgentStatus != "available" || last.CPU != res.CPU || last.Memory.Used != int64(2+i)*int64(mib) || last.Memory.Observation.State != "current" { + t.Errorf("poll %d lost independent Windows readings: disk=%d reason=%q guest=%q memory=%d/%s cpu=%v", i, last.Disk.Used, last.DiskStatusReason, last.GuestAgentStatus, last.Memory.Used, last.Memory.Observation.State, last.CPU) + } + } + if fileReads.Load() != 0 || fsReads.Load() != 3 || !reflect.DeepEqual(m.vmAgentMemCache[memoryKey], originalMemory) || !reflect.DeepEqual(m.guestMetadataCache[metadataKey], originalMetadata) { + t.Errorf("known Windows sent Linux work or renewed evidence: file=%d fs=%d", fileReads.Load(), fsReads.Load()) + } + for metric, want := range map[string][]float64{"disk": {30, 40, 0}, "cpu": {10, 20, 30}, "memory": {37.5, 50, 62.5}} { + points := guestHistoryStoredPoints(t, m, "vm", id, metric) + var values []float64 + for _, point := range points { + values = append(values, point.Value) + } + if !reflect.DeepEqual(values, want) || len(m.metricsHistory.GetGuestMetrics(id, metric, time.Hour)) != 3 { + t.Errorf("Windows %s History = %v, want %v", metric, values, want) + } + } + // OS evidence never bypasses a current operation lock. + beforeConfigs := configReads.Load() + locked.Store(true) + blocked := build() + if blocked.GuestAgentStatus != "deferred" || blocked.DiskStatusReason != "prev-vm-locked" || fsReads.Load() != 3 || fileReads.Load() != 0 || configReads.Load() <= beforeConfigs || blocked.Disk.Used != last.Disk.Used { + t.Error("Windows classification relaxed backup-lock admission") + } + locked.Store(false) + if resumed := build(); resumed.GuestAgentStatus != "available" || resumed.DiskStatusReason != "" || resumed.Disk.Used != 0 || fsReads.Load() != 4 || fileReads.Load() != 0 { + t.Error("normal unlocked Windows disk polling did not resume") + } + }) + } + } +}