mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user