Preserve guest identity and History consistency with accessible backup evidence (#2518)

## Outcome

This batch makes guest identity persistence safer, keeps broadcast
resource rows and their History targets in one captured generation, and
makes completed-backup evidence accessible by keyboard and touch.
Existing help now explains container CPU scales and passive comparisons.
Native macOS guidance checks receive real GNU timeout without replacing
unrelated system utilities.

The work follows retained identity-write/shutdown failures, fleet CPU
and History consistency evidence, inaccessible backup disclosures and
confusion about container CPU readings. It improves those existing paths
without claiming field recovery or complete performance relief.

## Included reviewed candidates

The complete proposed range is
`800329cbd83c48fdb7402a18633189f2c8a2d6ff..9b057cb141221526a96fac05bdbdf8ed6e8625fc`:

- Guest metadata ordering/coalescing and safe retained shutdown:
`244d322dce` and `5e51069048`, integrated at `d7a305922a`.
- Native guidance prerequisites: `13f6d10798` and `443faa53b6`,
integrated at `f7fca0e164`.
- Accessible existing Workloads backup details: `02e6a90374`, integrated
at `8661c1fc65`.
- Paired complete resource/History capture and faithful full-content
controls: `54de7bdca7`, `32f09dc13d`, `8b12e0ee1b`, `d206b03990` and
`9cc8523881`, integrated at `2787b4eabd`.
- Container CPU documentation: `0c283d2dd5`, integrated at `15e8c5ccf6`.
- Router-owned limiter joins and sealed late worker admission:
`5b560521e2`.

Reviewed identities and authors are unchanged. Current upstream Docker
link editing and platform layout repairs are incorporated by an additive
full-SHA merge; earlier upstream governance and Kubernetes layout
history are retained. The later upstream Docker image badge sizing
repair at `800329cbd83c48fdb7402a18633189f2c8a2d6ff` is also retained
after one successful bounded refresh and additive merge.

## Behaviour and safeguards

Identity polls merge only monitor-owned fields, preserve operator
links/tags/notes and OCI classification, and do not spin-retry
filesystem failures. Incomplete tenant shutdown retains ownership and
prevents directory removal. Router shutdown joins its own limiters
without resetting budgets or stopping another Router.

Broadcast resources and target mappings are detached together. Health
comparison controls use one evaluation instant while production still
samples real UTC at the existing boundary. Full-content, overlay/clear,
identity and observation-time controls remain.

Backup disclosures preserve hover and add keyboard/touch access to the
same read-only evidence. CPU guidance distinguishes host-capacity
normalisation, VM CPU counts, version-qualified rounding, missing
readings and actual alert delivery; it asks for ordinary observations,
not forced workload or threshold changes.

## Validation and remaining limits

- Provenance, retained ancestry, clean additive merge and whitespace
checks pass.
- Every unpublished non-merge commit passes the canonical
completion/history guard. Final-range browser receipt coverage, public
registry audit and contract audit pass.
- Accepted candidate evidence includes focused writer/API races, startup
observations, vet and actual builds; 363 connected backup frontend tests
with four desktop/touch browser cases; CPU help's 132 connected frontend
tests, lint/types/build/budgets, focused Go checks and four Docs browser
cases. Browser receipts cover unchanged final backup bytes and are not
rerun merely because the batch contains several candidates.
- Gate frontend proof at `9daeed13`: 246 tests in four connected files,
type-check, all lint checks, actual embed build and bundle budgets pass;
complete output hash matches and guest stop completes.
- Exact refreshed head `9b057cb1` passes 49 Docker native-table and
platform-layout guardrail tests (`source-l8diazgn`); complete output and
guest stop are verified. This covers the later width-only upstream
change without repeating the earlier full frontend checks.
- Gate backend attempt `source-pz6qwk2t` fails in guest readiness after
capacity waiting, client exit 2, with no command verdict and confirmed
guest stop. It is not a test pass and is not replayed. All backend Go
source, manifests and native workflow bytes are unchanged from reviewed
`5b560521` and its focused races/vet/real-build evidence; that evidence
remains usable without inventing an aggregate pass.
- The retained broad API ten-minute timeout, earlier drain/startup
failures, adverse allocation/frame costs and failed stable RSS retest
are not erased by these focused results. Required exact-head CI remains
authoritative for landing.
- This is not native guest thaw/recovery, PBS badge agreement, CPU/RSS
relief, notification delivery or release inclusion. Independent thaw,
fresh writes on every covered filesystem, liveness and only-prior-active
restoration remain necessary before resuming uncertain monitoring.

Related to #2199
Related to #1723
Related to #2439
Related to #2447


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-05 15:01:07 +00:00
committed by GitHub
46 changed files with 3350 additions and 283 deletions
@@ -84,6 +84,26 @@ jobs:
go-version-file: go.mod
cache: true
- name: Supply GNU timeout for macOS guidance checks
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
brew install coreutils
gnu_timeout="$(brew --prefix coreutils)/bin/gtimeout"
test -x "$gnu_timeout"
"$gnu_timeout" --version | grep -q 'GNU coreutils'
timeout_bin="$(mktemp -d "$RUNNER_TEMP/pulse-guidance-tools.XXXXXX")"
ln -s "$gnu_timeout" "$timeout_bin/timeout"
printf '%s\n' "$timeout_bin" >> "$GITHUB_PATH"
- name: Verify native Unix timeout prerequisite
if: matrix.unix
shell: bash
run: |
set -euo pipefail
timeout --version | grep -q 'GNU coreutils'
- name: Test native agent runtime
run: >-
go test
+51
View File
@@ -123,6 +123,57 @@ services:
---
## Container CPU readings
Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
normally use **100% for one logical CPU**, so their readings can exceed 100%.
For that scale, divide the stats percentage by the CPU count reported for the
runtime host to compare it with Pulse.
If the engine runs inside a VM, use that VM's logical CPUs, not the physical
Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
own CPU quota or cpuset. If the reported CPU count differs from your configured
VM size, retain both in the comparison rather than guessing a divisor.
| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
| --- | --- | --- |
| 4 | 240% | 60% |
| 6 | 16.52% | About 2.75% |
| 6 | 1.04% | About 0.17% |
These are examples, not an assumption about your host. A smaller Pulse value
alone does not establish a collection fault. Whole-percent table labels,
including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
as 0%. Use **CPU History** rather than the rounded label; display rounding does
not change stored readings or the values used by CPU alert evaluation. A dash
or missing History is unavailable data, not a measured zero.
For a useful comparison:
1. Match the same container on the same runtime host. Compare a similar time
interval in CPU History with `docker stats` or `podman stats`; separate
sampling intervals can legitimately differ.
2. Apply the reported host CPU count only to a stats reading on the per-CPU
scale. Do not divide Pulse History again, or compare with an average since
container start as though it were a recent interval.
3. Check that observation times and History points advance during ordinary
monitoring. A connection marked Active does not prove fresh CPU samples.
4. If the normalised readings still disagree, or History stops advancing,
report the server and agent versions, reported CPU count, sample times and
bounded readings. Do not restart workloads, create CPU load, lower alert
thresholds or export diagnostics just to make this comparison.
CPU alerts use the same host-capacity scale, with the configured thresholds,
duration and suppression rules. Non-zero History proves a collected reading,
not that an alert should fire or that its notification was delivered. If a
normal workload already demonstrates an alert problem, retain the reading,
threshold, duration and observed result; no forced alert test is needed. See
[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
when measuring the Pulse server itself rather than a monitored container.
---
## 🔄 Updates
These steps update the Pulse server, not the containers it monitors. Record the
+14
View File
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
setup change during a maintenance window.
#### Container CPU differs from Docker or Podman stats
Pulse uses the runtime host's total CPU capacity; `docker stats` and
`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
by the **reported runtime host CPU count**, not the physical Proxmox host's
count when the engine runs inside a VM. Compare the same container and time
interval in **CPU History**, not just a rounded table label. A dash or missing
History is unavailable, not zero; an Active connection alone is not fresh CPU
evidence.
See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
examples, rounding and alert limits, and a passive comparison. Do not restart
workloads, create CPU load or lower alert thresholds to test this.
#### Docker hosts appearing/disappearing
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
@@ -7520,13 +7520,26 @@ coarse activity class (`list`, `export`, `verify`, `summary`); the handler runs
unchanged whether or not recording succeeds, and a router without persistence
serves the request rather than failing it
(`TestWithAuditReadActivity_NilPersistenceIsSafe`).
### Monitor shutdown drains queued guest metadata writes
### Monitor shutdown seals guest metadata admission and retains incomplete teardown
`Monitor.Stop` seals guest identity/metadata mutations and waits under its
unchanged two-second metadata budget. One store-owned snapshot writer coalesces
changes admitted during filesystem I/O; an older queued identity cannot overwrite
an operator edit or reverse a newer accepted identity. `Close` reports both an
incomplete drain and a final persistence error, while the public void Stop logs
failure rather than claiming a successful stop.
Tenant removal must first observe its monitoring loop exit and then a successful
metadata close. Failure retains the monitor, cancellation/done handles and
lifecycle guard: lazy recreation cannot start a second writer in the same
directory. `BeginTenantDeletion` returns that failure to the organisation
handler, which keeps persistence and offboarding callbacks untouched. Only
successful shutdown allows directory deletion. A later explicit shutdown attempt
may persist retained metadata after a completed write failure is repaired;
admission stays sealed and no in-flight attempt is replayed. Other tenants keep their existing
independent runtime. Store drain does not establish independent guest thaw,
workload liveness or containment in installed software.
`Monitor.Stop` waits for in-flight `GuestMetadataStore` writes before closing
the metrics store, so a tenant monitor that has been stopped is guaranteed not
to write into its data directory afterwards. Tenant offboarding and any caller
that removes a tenant directory can rely on `Stop` having quiesced disk writes,
rather than racing a detached goroutine.
### Per-tenant resource stores are released on offboarding and shutdown
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the
@@ -20,6 +20,20 @@
## Purpose
### Organisation deletion retains data after incomplete monitoring shutdown
Authenticated organisation-owner deletion waits for tenant-loop exit and sealed
guest metadata persistence before removing the directory or calling offboarding.
An incomplete drain or final write failure returns HTTP 503 with the existing
error envelope and `tenant_shutdown_incomplete`; the message says the data was
retained and exposes no filesystem path or underlying error. The runtime owner
and recreation guard stay retained. Default-organisation immutability, session
and membership checks, successful HTTP 204 and other tenants' access are
unchanged. `TestOrgDeleteRetainsDataWhenGuestMetadataPersistenceFails` exercises
a real confined rename failure through the ordinary handler and metadata store;
existing successful deletion and authorization controls remain required.
### Optional guest-memory observation reaches existing frontend consumers
Frontend Memory and ResourceMetric model the existing optional server-owned
@@ -11207,3 +11221,20 @@ Pinning the Patrol attention detail header on phones
layout-only change. It reads no new attention fields, sends no new request,
and leaves the attention projection, lifecycle, and queue ordering contracts
above unchanged.
### Router-owned worker shutdown
Each Router owns its five route-specific limiters, eight endpoint-category
limiters and, in hosted mode, its tenant limiter. `ShutdownBackgroundWorkers`
seals lifecycle-worker admission, cancels and joins the constructor-owned
workers exactly once; the server's existing `ShutdownAIIntelligence` calls
that same lifecycle. HTTP admission must stop before shutdown. Resource and
RBAC stores retain their separate shutdown owners. Integration fixtures use
this lifecycle before stopping the monitor and removing temporary data.
`RateLimiter.Stop` is concurrent-safe and joins its cleanup worker. Stopping
cleanup does not clear request history, bypass rate enforcement or stop another
Router's or the package-global limiters. Limits, windows, tenant selection,
revocation and response payloads are unchanged. The connected lifetime and
ownership controls are in `router_limiter_lifecycle_test.go`; the integration
fixture's cleanup also runs on a failed listener/request assertion.
@@ -17,6 +17,36 @@
## Purpose
### Fresh broadcast projection owns one resource/target capture
Canonical live, fallback and standalone-host continuity broadcast reads prefer
the adapter's detached bulk resource/metrics-target capture. After host
coalescing selects presentation identity, missing history targets are attached
from that same capture, including authoritative absence. Stores without this
optional capability retain the previous point-resolver fallback. Only the owned
outer coalesced slice is decorated in place; nested source payloads stay read-only.
Every read still observes complete current resource data and derives live alert
health, metadata URL edits/clears and connected/ignored infrastructure. No
timestamp-only cache, telemetry omission, policy change or new wire field is
introduced. All existing catalogs, discovery/history coordinates, original
observation times, LastSeen, identity and tenant/revocation boundaries remain.
`broadcast_metrics_snapshot_test.go` compares the complete served JSON with an
independent earlier projection across the metadata-heavy nine-provider demo,
typed-view and same-time dirty cases; it also covers captured target replacement
and continuity/metadata/live-alert boundaries. `monitor_host_agents_test.go`
retains its one-materialization and live-change regression. Benchmarks include
the exact parent's complete conversion and final JSON encoding, with cold,
list-only, typed-clean and dirty inputs; they do not establish installed CPU/RSS
relief, native identity refresh throughput or completion of #2199.
Complete-content comparisons use one health-evaluation instant through a
private clock seam. Production still samples real UTC at the same post-read
boundary. The minute-boundary control requires age labels to advance without
renewing LastSeen; comparisons retain every health field instead of stripping
the reproduced `1m`/`2m` clock difference from JSON.
### Report identity lookups do not rebuild unrelated Kubernetes metadata
The producer's two temporary host/Docker-host admission registries use a
@@ -4581,27 +4611,45 @@ host dataset evidence is copied onto the matching provider-owned ZFS pool;
provider health, scan, device, and error fields remain authoritative. When the
provider cannot return pool detail, monitoring may synthesize only a minimal
`UNKNOWN` pool so valid dataset evidence is still inspectable.
### Guest metadata writes are owned by the store and drained on shutdown
### Guest identity admission coalesces owned JSON snapshots
`persistGuestIdentity` no longer detaches its own goroutine per changed guest.
It calls `GuestMetadataStore.SetAsync`, which tracks the write on a WaitGroup
so `GuestMetadataStore.WaitForPendingWrites` can drain it. `Monitor.Stop` drains
before closing the metrics store, under a bounded timeout matching
`tenantMonitorShutdownTimeout` so a wedged store cannot hold up tenant teardown.
`persistGuestIdentity` calls `GuestMetadataStore.RememberIdentity`, which merges
only the last-known name/type under the store lock, preserves operator URLs,
descriptions, tags and notes, and never downgrades OCI. Admission is visible
before return. `SetAsync` deep-copies its full replacement at admission as well;
caller mutation and goroutine scheduling cannot reverse accepted update order.
Untracked writes were observable, not theoretical: a queued write could land
after the monitor stopped and after a tenant directory was being removed,
leaving a stray `guest_metadata.json.tmp` from the interrupted atomic write.
That is what made `TestHostedTenantAgentInstallTokenCannotReportToOtherTenant`
fail its `t.TempDir` cleanup with "directory not empty".
`TestGuestMetadataStore_WaitForPendingWritesDrainsQueuedWrites` and
`TestGuestMetadataStore_DataDirIsRemovableAfterDrain` pin the drain and fail if
`SetAsync` stops tracking its goroutine.
One lazy store-owned writer snapshots the complete metadata map. Filesystem I/O
does not hold the memory lock, so changes admitted during that I/O are coalesced
into the next complete snapshot. A separate write mutex serializes it with
synchronous Set/Delete/ReplaceAll/UpdateAll and legacy migration; an older
snapshot cannot resurrect a deleted or migrated entry or overwrite an operator
edit. The existing tenant-local JSON shape, private file permissions and atomic
temporary-file/rename path stay unchanged. A failed write is reported and not
spin-retried; a later ordinary identity poll can retry the unpersisted state.
Known and deliberately unchanged: each changed guest still triggers a full-file
save, so one poll cycle over N changed guests performs N marshals and N atomic
writes that serialize on the store mutex. Coalescing them is a behavioural
change beyond the shutdown defect.
`WaitForPendingWrites` reports quiescence, not durable success. `Close` seals
mutation admission and observes the background writer and any admitted
synchronous write within the unchanged caller budget. Timeout or final
persistence failure remains an error. Monitor Stop logs that failure instead of
claiming a clean stop; tenant removal retains the runtime owner and lifecycle
guard, and organisation deletion refuses to remove its directory. An unfinished
monitoring loop likewise prevents deletion. Shutdown does not cancel an admitted
filesystem write or silently discard metadata to obtain a timely result. A later
explicit close may retry a completed failed snapshot after filesystem repair;
admission stays sealed, successful close is idempotent, and no close retries
itself or overlaps a still-running attempt.
`internal/config/guest_writer_control_test.go` exercises blocked snapshots at
25/100 updates and caller-copy ownership. `guest_metadata_writer_test.go` covers
ordered replacement, synchronous mutations, field-scoped identity, failure and
explicit recovery, bounded close with blocked synchronous/asynchronous I/O,
closed admission, real filesystem permissions and tenant separation. Existing
ten-second drain and cleanup controls remain unchanged. Monitoring shutdown
controls retain blocked writer/loop ownership; the real organisation-delete
handler returns its safe failure before offboarding or directory removal.
These are source controls, not an attribution of an earlier drain timeout,
native CPU/RSS relief or all-path guest-agent recovery.
### Monitoring projects canonical resource policy into alert evaluation
@@ -3744,3 +3744,26 @@ only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the
unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins
complete point preservation at the existing request bound. Neither claims a
timing benchmark, fleet CPU attribution or native incident relief.
### Accessible Workloads backup evidence
Both the Backup column badge and its hidden-column name indicator are native
read-only disclosure buttons. Keyboard activation and an ordinary first touch
open the same named-guest completion evidence that fine-pointer hover shows.
Exact time and age use readable theme text rather than colour alone. The shared
Dialog owns focus, Escape/backdrop dismissal and background isolation, and
returns focus to the originating disclosure. Nested evidence activation must
not trigger the enclosing guest row. Touch targets are at least 44px; compact
desktop shields and the existing status colours remain.
The evidence content is reactive to the same guest identity: running is not
completion, malformed/future times are unknown, and only the existing absence
sentinels mean no backup recorded. A removed row unmounts its disclosure and
restores dialog isolation. Opening details adds no API/history read, collector,
permission, backup/guest operation or polling timer. Dialog machinery and
content are mounted only when opened; existing freshness thresholds and source
projection are unchanged. `GuestRow.test.tsx` covers both nested row paths and
`BackupStatusAccess.test.tsx` covers semantics, focus and observation changes.
Browser proof exercises production rows with synthetic data, not native backup
or guest-recovery acceptance.
@@ -23,6 +23,29 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Broadcast bulk reads capture resources and history coordinates together
`ListWithMetricsTargets` and the monitor adapter's matching optional read return
detached resources and a detached target map from one locked registry generation.
The existing source-mapping inversion runs once for this read when typed views
are dirty; it does not build all typed views or add a retained generation cache.
Clean typed views may supply their already-current inverse mapping. Legacy List,
GetAll and individual target reads preserve their shapes and semantics.
The target map stays separate from resources until presentation host coalescing
selects the row identity. Missing coordinates remain missing in that capture;
they never fall through to a later live registry. Mapping changes with unchanged
LastSeen take effect on the next read. Returned resources, policy/identity blocks
and target values remain caller-owned across mutations and adapter replacement.
Projection cannot pair an earlier resource row with a later history identity.
`registry_test.go`, `registry_projection_test.go` and
`monitor_adapter_read_state_test.go` exercise full resource/point-target parity,
same-time edits, concurrent mapping writes, detachments, empty/nil reads and
tenant separation. Connected monitoring proof covers all nine demo providers,
continuity overlays and complete served JSON. Paired cold/clean/dirty benchmarks
measure whole materialization, not a new point-lookup envelope or native relief.
Canonical resource History honours selected memory observation provenance and Proxmox VM filesystem deferrals instead of treating numeric carriers as fresh measurements.
### Canonical resource History cannot promote retained guest readings
@@ -0,0 +1,359 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace';
const frontend = path.join(root, 'frontend-modern');
const output = path.join(root, 'tmp/backup-details-access/browser');
const origin = 'http://127.0.0.1:5298';
const runtime = [
'frontend-modern/src/components/Workloads/GuestRowCells.tsx',
'frontend-modern/src/components/Workloads/GuestRow.tsx',
];
const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync(path.join(root, 'tests/integration/package-lock.json'))).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((source) => [source, hash(path.join(root, source))]),
);
process.chdir(frontend);
const { createServer } = await import(
path.join(frontend, 'node_modules/vite/dist/node/index.js')
);
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5298, strictPort: true },
});
const results = [];
const captures = [];
const phases = [];
const started = Date.now();
const mark = (stage) => {
const fact = {
stage,
elapsedMs: Date.now() - started,
memory: process.memoryUsage(),
resources: process.resourceUsage(),
};
phases.push(fact);
console.log(JSON.stringify({ browserPhase: fact }));
};
let browser;
let phase = 'server';
try {
mark('listen-start');
await server.listen();
mark('listen-complete');
// Supported Vite transform warmup, not a production build or app execution.
// Keep page navigation and the tool deadline unchanged; collect phases.
await server.warmupRequest('/browser-tests/backup-details-access.tsx');
await server.warmupRequest('/src/index.css');
mark('entry-css-warmup-complete');
for (const [name, engine, phone, dark] of [
['desktop-light', chromium, false, false],
['desktop-dark', chromium, false, true],
['phone-light', webkit, true, false],
['phone-dark', webkit, true, true],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width: phone ? 390 : 1365, height: phone ? 844 : 900 },
isMobile: phone,
hasTouch: phone,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(12000);
page.setDefaultNavigationTimeout(45000);
await page.clock.setFixedTime(new Date('2026-10-05T08:00:00Z'));
const errors = [];
const operations = [];
const reads = [];
const checks = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.route('**/*', (route) => {
const request = route.request();
const url = new URL(request.url());
assert.equal(url.origin, origin, 'no off-origin requests');
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') operations.push(`${request.method()} ${url.pathname}`);
reads.push(url.pathname);
return route.fulfill({
json: { success: true, data: [], alerts: [], config: null, anomalies: [] },
});
});
if (dark)
await page.addInitScript(() =>
document.addEventListener('DOMContentLoaded', () =>
document.documentElement.classList.add('dark'),
),
);
mark(`${name}:navigate-start`);
await page.goto(`${origin}/browser-tests/backup-details-access.html`, {
waitUntil: 'domcontentloaded',
});
mark(`${name}:domcontentloaded`);
const row = page.getByRole('region', { name: 'backup-vm row' });
const badge = row.getByRole('button', { name: /^Backup status:/ });
await badge.waitFor();
const identity = await page.evaluate(() => window.__backupDetails.identity());
const rowActionCount = async () =>
Number(await page.getByRole('status', { name: 'Row action count' }).innerText());
const activate = async (el) => (phone ? el.tap() : el.click());
const capture = async (suffix) => {
const file = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: file, fullPage: true });
captures.push({
path: path.relative(root, file),
bytes: fs.statSync(file).size,
sha256: hash(file),
});
};
const dialog = page.getByRole('dialog', { name: /^Backup status details for / });
const close = page.getByRole('button', { name: 'Close backup details' });
const checkOpen = async (trigger) => {
await dialog.waitFor({ state: 'visible' });
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
assert.equal(await rowActionCount(), 0, 'backup action must not open the guest row');
await close.waitFor();
await page.waitForFunction(
() => document.activeElement?.getAttribute('aria-label') === 'Close backup details',
);
// DOM visibility can precede the CSS entrance animation. Verify and
// capture the final user-visible panel, not its initial off-screen frame.
await dialog.evaluate(async (el) => {
await Promise.all(
el
.getAnimations({ subtree: true })
.filter((a) => Number.isFinite(a.effect?.getTiming().iterations))
.map((a) => a.finished.catch(() => {})),
);
});
const rect = await dialog.boundingBox();
assert(
rect.x >= 0 && rect.x + rect.width <= (phone ? 390 : 1365),
'dialog stays within viewport',
);
assert(
rect.y >= 0 && rect.y + rect.height <= (phone ? 844 : 900),
'settled panel stays vertically in view',
);
assert(
await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
'background isolated',
);
};
const checkClosed = async (trigger) => {
await dialog.waitFor({ state: 'hidden' });
assert.equal(await trigger.getAttribute('aria-expanded'), 'false');
assert(
await trigger.evaluate((el) => document.activeElement === el),
'focus returns to source evidence',
);
assert.equal(
await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
false,
);
};
assert.equal(await badge.getAttribute('aria-haspopup'), 'dialog');
assert.equal((await badge.innerText()).trim(), '', 'fresh shield remains compact');
if (!phone) {
await badge.hover();
const tooltip = page.locator('[data-tooltip-portal="true"]');
await tooltip.waitFor({ state: 'visible' });
assert((await tooltip.innerText()).includes('Last completed backup'));
await page.getByRole('heading').hover();
// Genuine sequential keyboard traversal, not synthetic click/focus.
await page.keyboard.press('Tab'); // column control
await page.keyboard.press('Tab'); // guest disclosure
await page.keyboard.press('Tab'); // backup disclosure
assert(await badge.evaluate((el) => document.activeElement === el));
await page.keyboard.press('Enter');
checks.push('desktop hover preserved and sequential Tab/Enter opens actual badge');
} else {
const rect = await badge.boundingBox();
assert(rect.width >= 44 && rect.height >= 44, 'touch disclosure is at least 44px');
await activate(badge);
assert.equal(
await page.locator('[data-tooltip-portal="true"]').count(),
0,
'no synthetic hover overlay',
);
checks.push('ordinary first tap opens 44px target, without hover UI');
}
await checkOpen(badge);
assert((await dialog.innerText()).includes('backup-vm'));
assert((await dialog.innerText()).includes('Mon, 5 Oct 2026'));
assert((await dialog.innerText()).includes('06:00:00'));
assert((await dialog.innerText()).includes('2 hours ago'));
await capture('fresh-completed');
await page.keyboard.press('Tab');
assert(
await close.evaluate((el) => document.activeElement === el),
'one-action dialog retains focus',
);
await page.evaluate(() =>
window.__backupDetails.update({ timestamp: '2026-10-05T06:00:00Z', running: true }),
);
assert((await dialog.innerText()).includes('Backup running now'));
assert((await dialog.innerText()).includes('Last completed backup'));
assert.equal(await page.evaluate(() => window.__backupDetails.identity()), identity);
checks.push('same identity updates running while preserving prior completed observation');
await capture('running-completed');
await page.evaluate(() =>
window.__backupDetails.update({ timestamp: '2026-10-05T08:01:00Z', running: true }),
);
await dialog.getByText(/timestamp is in the future/).waitFor();
assert(!(await dialog.innerText()).includes('Last completed backup'));
assert(!(await dialog.innerText()).includes('No backup has ever'));
await capture('future-running');
await page.keyboard.press('Escape');
await checkClosed(badge);
if (!phone) {
await page.keyboard.press('Space');
await checkOpen(badge);
assert((await dialog.innerText()).includes('timestamp is in the future'));
await page.keyboard.press('Escape');
await checkClosed(badge);
checks.push('native Space reopens current evidence and Escape restores focus');
}
await page.getByRole('button', { name: 'Hide Backup column' }).click();
const indicator = row.getByRole('button', { name: /^Backup running now/ });
await activate(indicator);
await checkOpen(indicator);
assert((await dialog.innerText()).includes('timestamp is in the future'));
checks.push('hidden-column indicator exposes exactly the same uncertainty');
await page.evaluate(() =>
window.__backupDetails.update({ timestamp: 'bad-time', running: false }),
);
await dialog.getByText(/invalid timestamp/).waitFor();
await page.evaluate(() => window.__backupDetails.update({ timestamp: '', running: false }));
await dialog
.getByText('No backup has ever been recorded for this guest.', { exact: true })
.waitFor();
assert(!(await dialog.innerText()).includes('Invalid Date'));
assert(!(await dialog.innerText()).includes('NaN'));
checks.push('malformed then absence retain distinct truthful states');
await activate(close);
await checkClosed(row.getByRole('button', { name: 'No backup found', exact: true }));
const ct = page
.getByRole('region', { name: 'backup-ct row' })
.getByRole('button', { name: 'No backup found', exact: true });
await activate(ct);
await checkOpen(ct);
assert((await dialog.innerText()).includes('backup-ct'));
assert(
!(await dialog.innerText()).includes('2 hours ago'),
'adjacent guest never inherits another observation',
);
await capture('ct-missing');
await page.locator('[data-dialog-backdrop]').click({ position: { x: 3, y: 3 } });
await checkClosed(ct);
await page.evaluate(() =>
window.__backupDetails.update({ timestamp: '2026-10-05T06:00:00Z', running: false }),
);
const recovered = row.getByRole('button', { name: /^Last backup:/ });
await activate(recovered);
await checkOpen(recovered);
assert((await dialog.innerText()).includes('2 hours ago'));
await page.evaluate(() => window.__backupDetails.remove());
await dialog.waitFor({ state: 'hidden' });
assert.equal(await page.locator('[data-dialog-layer]').count(), 0);
assert.equal(
await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
false,
);
checks.push(
'recovery and actual row removal release dialog/inertness without extra operations',
);
assert.equal(await rowActionCount(), 0);
await activate(
page.getByRole('region', { name: 'backup-ct row' }).getByText('backup-ct', { exact: true }),
);
assert.equal(await rowActionCount(), 1, 'ordinary row action still works');
assert.deepEqual(errors, []);
assert.deepEqual(operations, []);
assert(
!reads.some((p) => /backup|metrics|guest|resources/.test(p)),
'opening evidence adds no collector/history read',
);
results.push({
name,
browser: browser.version(),
checks,
apiReads: reads,
operations,
errors,
finalRowActions: await rowActionCount(),
});
mark(`${name}:checks-complete`);
await page.close();
await browser.close();
browser = undefined;
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: 'passed',
playwrightVersion: version,
runtime: hashes,
phases,
results,
captures,
},
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
result: 'passed',
cases: results.length,
checks: results.reduce((n, r) => n + r.checks.length, 0),
captures: captures.length,
hashes,
playwrightVersion: version,
}),
);
} catch (e) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify(
{
result: 'failed',
phase,
error: String(e),
runtime: hashes,
phases,
playwrightVersion: version,
captures,
results,
},
null,
2,
) + '\n',
);
throw e;
} finally {
if (browser) await browser.close();
await server.close();
mark('browser-server-closed');
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup evidence accessibility verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./backup-details-access.tsx"></script>
</body>
</html>
@@ -0,0 +1,110 @@
// Production Workloads rows/shared dialog, with explicit guest observations.
// Mapping/collection are unchanged. No backup, guest, thaw or credential operation.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { DarkModeContext } from '../src/contexts/appRuntime';
import type { WorkloadGuest } from '../src/types/workloads';
import '../src/index.css';
type Observation = { timestamp: string; running: boolean };
const guest = (vmid: number, value: Observation): WorkloadGuest => ({
id: `fixture-pve1-${vmid}`,
vmid,
name: vmid === 101 ? 'backup-vm' : 'backup-ct',
node: 'pve1',
instance: 'fixture',
status: 'running',
type: vmid === 101 ? 'qemu' : 'lxc',
workloadType: vmid === 101 ? 'vm' : 'system-container',
platformType: 'proxmox-pve',
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 1024 ** 3 / 4, free: (3 * 1024 ** 3) / 4, usage: 25 },
disk: { total: 10 * 1024 ** 3, used: 0, free: 10 * 1024 ** 3, usage: 0 },
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: value.timestamp === '' ? 0 : Date.parse(value.timestamp),
backupInProgress: value.running,
tags: null,
lock: value.running ? 'backup' : '',
lastSeen: '2026-10-05T08:00:00Z',
});
const initial: Observation = { timestamp: '2026-10-05T06:00:00Z', running: false };
function Fixture() {
const [vm, setVM] = createSignal(guest(101, initial));
const ct = guest(102, { timestamp: '', running: false });
const [vmPresent, setVMPresent] = createSignal(true);
const [actions, setActions] = createSignal(0);
const [showBackup, setShowBackup] = createSignal(true);
(window as any).__backupDetails = {
update: (value: Observation) => setVM(guest(101, value)),
remove: () => setVMPresent(false),
identity: () => vm().id,
};
return (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Backup evidence accessibility</h1>
<p class="text-xs text-muted">
Synthetic guest observations, no backup commands or collector reads.
</p>
<button
class="rounded-sm border border-border p-2 text-xs focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => setShowBackup(!showBackup())}
>
{showBackup() ? 'Hide Backup column' : 'Show Backup column'}
</button>
<Show when={vmPresent()}>
<section aria-label="backup-vm row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={vm()}
visibleColumnIds={showBackup() ? ['name', 'backup'] : ['name']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
onClick={() => setActions((n) => n + 1)}
/>
</tbody>
</table>
</section>
</Show>
<section aria-label="backup-ct row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={ct}
visibleColumnIds={['name']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
onClick={() => setActions((n) => n + 1)}
/>
</tbody>
</table>
</section>
<p role="status" aria-label="Row action count">
{actions()}
</p>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,117 @@
// The real Docs renderer, router and shipped help. No runtime socket, workload,
// credentials, alert test or diagnostics collection is exercised.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'container-cpu-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root, configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-container-cpu-help'),
server: { host: '127.0.0.1', port: 5265, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [
['chromium', 1280, 'light'], ['chromium', 1280, 'dark'],
['webkit', 390, 'light'], ['webkit', 390, 'dark'],
]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
const operations = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
page.on('request', (request) => {
if (request.url().includes('/api/') || request.method() !== 'GET')
operations.push({ method: request.method(), url: request.url() });
});
await page.goto('http://127.0.0.1:5265/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const entry = page.getByRole('heading', {
name: 'Container CPU differs from Docker or Podman stats', exact: true,
});
await entry.waitFor();
await entry.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-entry.png`) });
const guideLink = page.getByRole('link', { name: 'container CPU readings', exact: true });
if (engine === 'webkit') await guideLink.tap();
else {
await guideLink.focus();
await page.keyboard.press('Enter');
}
const heading = page.getByRole('heading', { name: 'Container CPU readings', exact: true });
await heading.waitFor();
await page.waitForFunction(() => {
const h = document.getElementById('container-cpu-readings');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/DOCKER#container-cpu-readings'));
const article = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
for (const phrase of ["Docker/Podman host's total CPU capacity", '100% for one logical CPU',
'not an assumption about your host', 'v6.5.0-rc.1', 'unavailable data, not a measured zero',
'Do not divide Pulse History again', 'Active does not prove fresh CPU samples',
'not that an alert should fire or that its notification was delivered',
'Do not restart workloads, create CPU load, lower alert thresholds'])
assert.ok(article.includes(phrase), phrase);
const table = page.locator('article table').filter({ hasText: 'Example host logical CPUs' });
assert.equal(await table.getByRole('row').count(), 4);
assert.equal(await table.getByRole('columnheader').count(), 3);
const rows = await table.getByRole('row').allTextContents();
assert.ok(rows[1].includes('240%') && rows[1].includes('60%'));
assert.ok(rows[2].includes('16.52%') && rows[2].includes('2.75%'));
assert.ok(rows[3].includes('1.04%') && rows[3].includes('0.17%'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await table.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-examples.png`) });
const performance = page.getByRole('link', { name: 'safe performance measurements', exact: true });
if (engine === 'webkit') await performance.tap();
else {
await performance.focus();
await page.keyboard.press('Enter');
}
await page.getByRole('heading', { name: 'Excessive CPU, writes or database growth', exact: true }).waitFor();
await page.waitForFunction(() => {
const h = document.getElementById('excessive-cpu-writes-or-database-growth');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#excessive-cpu-writes-or-database-growth'));
assert.deepEqual(errors, []);
assert.deepEqual(operations, []);
results.push({ engine, version: browser.version(), width, tone, exampleRows: rows,
entryLink: engine === 'webkit' ? 'touch' : 'keyboard', performanceLink: true,
noDocumentOverflow: true, errors, operations });
await browser.close();
browser = undefined;
}
const docs = ['DOCKER.md', 'TROUBLESHOOTING.md'].map((name) => ({ name,
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'public', 'docs', name))).digest('hex'),
}));
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped CPU help rendering and navigation, not collector accuracy or alert delivery', docs, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "7387703d23ca855539e67855389712d780d2a868",
"verified_at": "2026-10-05T10:40:50.589686Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/GuestRowCells.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRowCells.tsx": "90cc2bac8ab59556148aac49026aef6d18615a4d85ef39891f8d51ddac804681",
"frontend-modern/src/components/Workloads/GuestRow.tsx": "6668d1f0f6aba4d79e006adc80ac2b323445e624f3e462de4373cdd4a9d5c6e3"
},
"routes": ["/browser-tests/backup-details-access.html"],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Production Workloads GuestRow, both backup disclosures, shared Dialog and CSS with explicit synthetic VM/CT observations. Desktop Chromium and touch WebKit, each light/dark. Playwright 1.56.1 matches the integration lock.",
"Completed date/time and age remain distinct from a running task. Same-ID running, future/malformed uncertainty, absence and recovery update open evidence without inventing completion.",
"Sixteen final screenshots opened: named VM/CT evidence and Close controls remain readable and within desktop/phone bounds after the finite entrance animation.",
"Final fixture does not exercise canonical projection or native backup correctness, PBS/source agreement, guest recovery, physical devices or published availability. Earlier timeouts and superseded source/capture failures are retained in the lane outcome."
],
"interactions": [
"Existing hover is preserved. Sequential Tab/Enter and native Space open the desktop badge; ordinary first touch opens a minimum-44px phone target without a hover tooltip.",
"Close focus, background inertness, focus containment and Escape/Close/backdrop dismissal return focus to the originating disclosure. Nested activation does not open the enclosing guest row.",
"Hidden-column indicator presents the same uncertainty and named guest as the Backup column. Adjacent CT has no VM observation. Recovery and actual row removal release dialog isolation; an ordinary CT row activation still works.",
"Four cases, 22 grouped checks, no page errors or operation requests. Only the existing anomaly fixture GET occurs. No collector/history/resource read is added. Browser/server closure completed under unchanged deadlines."
]
}
+51
View File
@@ -123,6 +123,57 @@ services:
---
## Container CPU readings
Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
normally use **100% for one logical CPU**, so their readings can exceed 100%.
For that scale, divide the stats percentage by the CPU count reported for the
runtime host to compare it with Pulse.
If the engine runs inside a VM, use that VM's logical CPUs, not the physical
Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
own CPU quota or cpuset. If the reported CPU count differs from your configured
VM size, retain both in the comparison rather than guessing a divisor.
| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
| --- | --- | --- |
| 4 | 240% | 60% |
| 6 | 16.52% | About 2.75% |
| 6 | 1.04% | About 0.17% |
These are examples, not an assumption about your host. A smaller Pulse value
alone does not establish a collection fault. Whole-percent table labels,
including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
as 0%. Use **CPU History** rather than the rounded label; display rounding does
not change stored readings or the values used by CPU alert evaluation. A dash
or missing History is unavailable data, not a measured zero.
For a useful comparison:
1. Match the same container on the same runtime host. Compare a similar time
interval in CPU History with `docker stats` or `podman stats`; separate
sampling intervals can legitimately differ.
2. Apply the reported host CPU count only to a stats reading on the per-CPU
scale. Do not divide Pulse History again, or compare with an average since
container start as though it were a recent interval.
3. Check that observation times and History points advance during ordinary
monitoring. A connection marked Active does not prove fresh CPU samples.
4. If the normalised readings still disagree, or History stops advancing,
report the server and agent versions, reported CPU count, sample times and
bounded readings. Do not restart workloads, create CPU load, lower alert
thresholds or export diagnostics just to make this comparison.
CPU alerts use the same host-capacity scale, with the configured thresholds,
duration and suppression rules. Non-zero History proves a collected reading,
not that an alert should fire or that its notification was delivered. If a
normal workload already demonstrates an alert problem, retain the reading,
threshold, duration and observed result; no forced alert test is needed. See
[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
when measuring the Pulse server itself rather than a monitored container.
---
## 🔄 Updates
These steps update the Pulse server, not the containers it monitors. Record the
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
setup change during a maintenance window.
#### Container CPU differs from Docker or Podman stats
Pulse uses the runtime host's total CPU capacity; `docker stats` and
`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
by the **reported runtime host CPU count**, not the physical Proxmox host's
count when the engine runs inside a VM. Compare the same container and time
interval in **CPU History**, not just a rounded table label. A dash or missing
History is unavailable, not zero; an Active connection alone is not fresh CPU
evidence.
See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
examples, rounding and alert limits, and a passive comparison. Do not restart
workloads, create CPU load or lower alert thresholds to test this.
#### Docker hosts appearing/disappearing
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
@@ -323,6 +323,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup') && supportsBackup()}>
<BackupIndicator
guestName={props.guest.name}
lastBackup={props.guest.lastBackup}
isTemplate={props.guest.template}
backupRunning={props.guest.backupInProgress}
@@ -845,6 +846,7 @@ export function GuestRow(props: GuestRowProps) {
>
<Show when={!props.guest.template}>
<BackupStatusCell
guestName={props.guest.name}
lastBackup={props.guest.lastBackup}
backupRunning={props.guest.backupInProgress}
/>
@@ -1,4 +1,6 @@
import { createMemo, For, Show } from 'solid-js';
import { createMemo, createSignal, For, Show } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
import { useTooltip } from '@/hooks/useTooltip';
@@ -46,28 +48,18 @@ function BackupShieldIcon(props: { icon: 'check' | 'warning' | 'x' | 'running';
function BackupIndicator(props: {
lastBackup: string | number | null | undefined;
isTemplate: boolean;
guestName?: string;
backupRunning?: boolean;
}) {
if (props.isTemplate) return null;
const alertsActivation = useAlertsActivation();
const backupInfo = createMemo(() =>
getBackupInfo(props.lastBackup, alertsActivation.getBackupThresholds()),
);
const displayStatus = createMemo<WorkloadsGuestBackupDisplayStatus>(() =>
props.backupRunning ? 'running' : backupInfo().status,
);
const config = createMemo(() => getWorkloadsGuestBackupStatusPresentation(displayStatus()));
const tooltipText = createMemo(() => {
const info = backupInfo();
return getWorkloadsGuestBackupTooltip(info.status, info.ageFormatted, props.backupRunning);
});
return (
<span class={`shrink-0 ${config().color}`} title={tooltipText()} aria-label={tooltipText()}>
<BackupShieldIcon icon={config().icon} pulse={displayStatus() === 'running'} />
</span>
<BackupStatusCell
lastBackup={props.lastBackup}
backupRunning={props.backupRunning}
guestName={props.guestName}
indicator
/>
);
}
@@ -316,8 +308,12 @@ function OSInfoCell(props: { osName: string; osVersion: string; agentVersion: st
function BackupStatusCell(props: {
lastBackup: string | number | null | undefined;
backupRunning?: boolean;
indicator?: boolean;
guestName?: string;
}) {
const tip = useTooltip();
const [detailsOpen, setDetailsOpen] = createSignal(false);
let trigger: HTMLButtonElement | undefined;
const alertsActivation = useAlertsActivation();
const info = createMemo(() =>
@@ -337,6 +333,13 @@ function BackupStatusCell(props: {
const hasCompletedBackup = createMemo(() => info().ageMs !== null);
const ariaLabel = createMemo(() => {
const currentInfo = info();
if (props.indicator) {
return getWorkloadsGuestBackupTooltip(
currentInfo.status,
currentInfo.ageFormatted,
props.backupRunning,
);
}
if (currentInfo.status === 'unknown') {
return `Backup status: ${props.backupRunning ? 'backup running now, ' : ''}${currentInfo.ageFormatted}`;
}
@@ -349,61 +352,111 @@ function BackupStatusCell(props: {
return `Backup status: ${currentInfo.status}, last backup ${currentInfo.ageFormatted}`;
});
// Both entry points reveal the same retained completion evidence. Hover
// stays a shortcut on a fine pointer; the native button opens a read-only
// dialog for keyboard/touch without stealing the enclosing row action.
const Evidence = () => (
<div class="space-y-2 text-xs">
<Show when={props.guestName}>
<p class="font-medium text-base-content wrap-break-word">{props.guestName}</p>
</Show>
<Show when={props.backupRunning}>
<p class="text-blue-600 dark:text-blue-400">Backup running now…</p>
</Show>
<Show when={hasCompletedBackup()}>
<div class="space-y-1">
<div class="text-muted">Last completed backup</div>
<time
class="block font-medium text-base-content"
dateTime={new Date(props.lastBackup!).toISOString()}
>
{new Date(props.lastBackup!).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{' · '}
{new Date(props.lastBackup!).toLocaleTimeString()}
</time>
<div class="text-base-content">{info().ageFormatted}</div>
</div>
</Show>
<Show when={info().status === 'unknown'}>
<p class="text-amber-700 dark:text-amber-300">{info().ageFormatted}</p>
</Show>
<Show when={info().status === 'never'}>
<p class="text-red-600 dark:text-red-400">
{props.backupRunning
? 'No completed backup yet - the first backup is running now.'
: 'No backup has ever been recorded for this guest.'}
</p>
</Show>
</div>
);
const focusClass =
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
return (
<>
<span
class={getBackupAgeBadgeClass(displayStatus())}
<button
ref={trigger}
type="button"
class={
props.indicator
? `inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-sm sm:min-h-0 sm:min-w-0 ${config().color} ${focusClass}`
: `${getBackupAgeBadgeClass(displayStatus())} min-h-11 min-w-11 sm:min-h-0 ${focusClass}`
}
onMouseEnter={tip.onMouseEnter}
onMouseLeave={tip.onMouseLeave}
onClick={(event) => {
event.stopPropagation();
tip.onMouseLeave();
setDetailsOpen(true);
}}
aria-label={ariaLabel()}
title={props.indicator ? ariaLabel() : undefined}
aria-haspopup="dialog"
aria-expanded={detailsOpen()}
>
<BackupShieldIcon icon={config().icon} pulse={displayStatus() === 'running'} />
<Show when={displayStatus() !== 'fresh'}>
<Show when={!props.indicator && displayStatus() !== 'fresh'}>
<span>{badgeLabel()}</span>
</Show>
</span>
</button>
<TooltipPortal when={tip.show()} x={tip.pos().x} y={tip.pos().y}>
<div class="min-w-[140px]">
<div class="font-medium mb-1 text-slate-300 border-b border-border pb-1">
Backup Status
<div class="min-w-[140px] max-w-[280px]">
<div class="mb-1 border-b border-border pb-1 font-medium text-base-content">
Backup status
</div>
<Show when={props.backupRunning}>
<div class="py-0.5 text-blue-400">Backup running now…</div>
</Show>
<Show when={hasCompletedBackup()}>
<div class="py-0.5">
<div class="text-slate-400">
{props.backupRunning ? 'Last completed backup' : 'Last backup'}
</div>
<div class="text-base-content font-medium">
{new Date(props.lastBackup!).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
<div class="text-slate-300">{new Date(props.lastBackup!).toLocaleTimeString()}</div>
</div>
<div class="pt-1 mt-1 border-t border-border">
<span class={getWorkloadsGuestBackupStatusPresentation(info().status).color}>
{info().ageFormatted}
</span>
</div>
</Show>
<Show when={info().status === 'unknown'}>
<div class="py-0.5 text-amber-400">{info().ageFormatted}</div>
</Show>
<Show when={info().status === 'never'}>
<div class="py-0.5 text-red-400">
{props.backupRunning
? 'No completed backup yet - the first backup is running now.'
: 'No backup has ever been recorded for this guest.'}
</div>
</Show>
<Evidence />
</div>
</TooltipPortal>
<Show when={detailsOpen()}>
<Dialog
isOpen
ariaLabel={`Backup status details${props.guestName ? ` for ${props.guestName}` : ''}`}
onClose={() => setDetailsOpen(false)}
returnFocus={() => trigger}
panelClass="max-w-sm"
>
<div class="flex items-center justify-between gap-3 border-b border-border px-4 py-2">
<h2 class="text-sm font-semibold text-base-content">Backup status</h2>
<button
type="button"
aria-label="Close backup details"
class="min-h-11 rounded-sm px-3 text-xs text-muted hover:bg-surface-hover focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => setDetailsOpen(false)}
>
Close
</button>
</div>
<div class="overflow-y-auto p-4">
<Evidence />
</div>
</Dialog>
</Show>
</>
);
}
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { BackupIndicator, BackupStatusCell } from '../GuestRowCells';
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ getBackupThresholds: () => undefined }),
}));
const completed = Date.parse('2026-10-05T06:00:00Z');
const now = Date.parse('2026-10-05T08:00:00Z');
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('backup evidence access', () => {
it.each(['badge', 'indicator'] as const)(
'regression: %s exposes completed-backup details through a native button',
async (variant) => {
vi.useFakeTimers();
vi.setSystemTime(now);
render(() =>
variant === 'badge' ? (
<BackupStatusCell lastBackup={completed} />
) : (
<BackupIndicator lastBackup={completed} isTemplate={false} />
),
);
const trigger = screen.getByRole('button', {
name: variant === 'badge' ? /^Backup status:/ : /^Last backup:/,
});
expect(trigger).toHaveAttribute('type', 'button');
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: 'Backup status details' });
expect(dialog).toHaveTextContent('Last completed backup');
expect(dialog).toHaveTextContent(
new Date(completed).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
}),
);
expect(dialog).toHaveTextContent('2 hours ago');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
await Promise.resolve();
expect(screen.getByRole('button', { name: 'Close backup details' })).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
expect(trigger).toHaveFocus();
},
);
it('keeps open details reactive without interpreting running or uncertainty as completion', () => {
vi.useFakeTimers();
vi.setSystemTime(now);
const [value, setValue] = createSignal<string | number>(completed);
const [running, setRunning] = createSignal(false);
render(() => <BackupStatusCell lastBackup={value()} backupRunning={running()} />);
fireEvent.click(screen.getByRole('button', { name: /^Backup status:/ }));
const dialog = screen.getByRole('dialog');
setRunning(true);
expect(dialog).toHaveTextContent('Backup running now');
expect(dialog).toHaveTextContent('Last completed backup');
setValue('not-a-date');
expect(dialog).toHaveTextContent('invalid timestamp');
expect(dialog).not.toHaveTextContent('Last completed backup');
expect(dialog).not.toHaveTextContent('No backup has ever');
setValue(now + 60000);
expect(dialog).toHaveTextContent('timestamp is in the future');
expect(dialog).not.toHaveTextContent('Invalid Date');
setValue(0);
expect(dialog).toHaveTextContent('No completed backup yet');
setRunning(false);
expect(dialog).toHaveTextContent('No backup has ever been recorded');
expect(dialog).not.toHaveTextContent('Backup running now');
setValue(completed);
expect(dialog).toHaveTextContent('2 hours ago');
expect(dialog).not.toHaveTextContent('unavailable');
});
it('uses separate disclosures for adjacent guests and omits template indicators', () => {
vi.useFakeTimers();
vi.setSystemTime(now);
render(() => (
<>
<BackupStatusCell lastBackup={completed} />
<BackupStatusCell lastBackup={0} />
<BackupIndicator lastBackup={completed} isTemplate={true} />
</>
));
const triggers = screen.getAllByRole('button', { name: /^Backup status:/ });
expect(triggers).toHaveLength(2);
fireEvent.click(triggers[0]);
fireEvent.click(screen.getByRole('button', { name: 'Close backup details' }));
fireEvent.click(triggers[1]);
expect(screen.getAllByRole('dialog')).toHaveLength(1);
expect(screen.getByRole('dialog')).toHaveTextContent('No backup has ever been recorded');
expect(screen.getByRole('dialog')).not.toHaveTextContent('2 hours ago');
expect(screen.queryByRole('button', { name: /^Last backup:/ })).toBeNull();
});
});
@@ -9,7 +9,7 @@ const renderCell = (lastBackup: string | number | null, backupRunning?: boolean)
const { container } = render(() => (
<BackupStatusCell lastBackup={lastBackup} backupRunning={backupRunning} />
));
const badge = container.querySelector('span[aria-label^="Backup status"]');
const badge = container.querySelector('button[aria-label^="Backup status"]');
if (!badge) throw new Error('backup badge not rendered');
return badge as HTMLElement;
};
@@ -2314,3 +2314,28 @@ describe('app-container update button visibility', () => {
expect(screen.queryByTestId('update-button')).toBeNull();
});
});
describe('GuestRow backup evidence disclosure', () => {
it.each([{ columns: ['name', 'backup'] }, { columns: ['name'] }])(
'opens read-only evidence without triggering the enclosing row for columns %j',
({ columns }) => {
const onClick = vi.fn();
renderGuestRow({
guest: makeGuest({ lastBackup: Date.now() - 3600000 }),
visibleColumnIds: columns,
onClick,
});
const trigger = screen.getByRole('button', {
name: columns.includes('backup') ? /^Backup status:/ : /^Last backup:/,
});
fireEvent.click(trigger);
expect(
screen.getByRole('dialog', { name: 'Backup status details for test-vm' }),
).toHaveTextContent('Last completed backup');
expect(onClick).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Close backup details' }));
expect(screen.queryByRole('dialog')).toBeNull();
expect(onClick).not.toHaveBeenCalled();
},
);
});
@@ -480,6 +480,40 @@ describe('docsLinks', () => {
);
});
it('explains container CPU scales without inventing a zero or forcing an alert', () => {
const docker = readFileSync(path.join(repoRoot, 'docs', 'DOCKER.md'), 'utf8');
const cpu = docker
.split('## Container CPU readings\n')[1]
.split('\n---')[0]
.replace(/\s+/g, ' ');
expect(cpu).toContain("Docker/Podman host's total CPU capacity");
expect(cpu).toContain('100% for one logical CPU');
expect(cpu).toContain('CPU count reported for the runtime host');
expect(cpu).toContain("not the physical Proxmox host's CPUs");
expect(cpu).toContain("not utilisation of the container's own CPU quota or cpuset");
expect(cpu).toContain('not an assumption about your host');
expect(cpu).toContain('v6.5.0-rc.1');
expect(cpu).toContain('not change stored readings or the values used by CPU alert evaluation');
expect(cpu).toContain('unavailable data, not a measured zero');
expect(cpu).toContain('Do not divide Pulse History again');
expect(cpu).toContain('separate sampling intervals can legitimately differ');
expect(cpu).toContain('Active does not prove fresh CPU samples');
expect(cpu).toContain('not that an alert should fire or that its notification was delivered');
expect(cpu).toContain('Do not restart workloads, create CPU load, lower alert thresholds');
expect(cpu).toContain('no forced alert test is needed');
const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8');
const entry = troubleshooting
.split('#### Container CPU differs from Docker or Podman stats\n')[1]
.split('\n#### ')[0]
.replace(/\s+/g, ' ');
expect(entry).toContain('DOCKER.md#container-cpu-readings');
expect(entry).toContain('reported runtime host CPU count');
expect(entry).toContain('same container and time interval');
expect(entry).toContain('unavailable, not zero');
expect(entry).toContain('Do not restart workloads, create CPU load or lower alert thresholds');
});
it('keeps clone identity recovery separate from destructive OS or credential resets', () => {
const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8');
const agentGuide = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8');
+5 -1
View File
@@ -387,7 +387,11 @@ func (h *OrgHandlers) HandleDeleteOrg(w http.ResponseWriter, r *http.Request) {
}
if h.mtMonitor != nil {
h.mtMonitor.BeginTenantDeletion(orgID)
if err := h.mtMonitor.BeginTenantDeletion(orgID); err != nil {
log.Error().Err(err).Str("org_id", orgID).Msg("Organization shutdown incomplete; retaining data")
writeErrorResponse(w, http.StatusServiceUnavailable, "tenant_shutdown_incomplete", "Organization monitoring has not shut down cleanly; its data has been retained", nil)
return
}
defer h.mtMonitor.FinishTenantDeletion(orgID)
}
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"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/monitoring"
)
func TestOrgDeleteRetainsDataWhenGuestMetadataPersistenceFails(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
previous := IsMultiTenantEnabled()
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(previous) })
root := t.TempDir()
persistence := config.NewMultiTenantPersistence(root)
mtm := monitoring.NewMultiTenantMonitor(&config.Config{DataPath: root}, persistence, nil)
t.Cleanup(mtm.Stop)
h := NewOrgHandlers(persistence, mtm)
// Exercise the same session-owner setup as ordinary organisation creation.
create := withUser(httptest.NewRequest(http.MethodPost, "/api/orgs", strings.NewReader(`{"id":"retained","displayName":"Retained"}`)), "alice")
rec := httptest.NewRecorder()
h.HandleCreateOrg(rec, create)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
org, err := persistence.LoadOrganization("retained")
if err != nil || org == nil {
t.Fatal(err)
}
monitor, err := mtm.GetMonitor("retained")
if err != nil {
t.Fatal(err)
}
orgPath := filepath.Join(root, "orgs", "retained")
// A real rename refusal, confined to this disposable test directory.
if err := os.Mkdir(filepath.Join(orgPath, "guest_metadata.json"), 0o700); err != nil {
t.Fatal(err)
}
if err := monitor.GuestMetadataStore().RememberIdentity("pve:node:100", "unsaved", "qemu"); err != nil {
t.Fatal(err)
}
if !monitor.GuestMetadataStore().WaitForPendingWrites(time.Second) {
t.Fatal("failed attempt did not quiesce")
}
called := false
h.SetOnDelete(func(context.Context, string) error { called = true; return nil })
req := withUser(httptest.NewRequest(http.MethodDelete, "/api/orgs/retained", nil), "alice")
req.SetPathValue("id", "retained")
rec = httptest.NewRecorder()
h.HandleDeleteOrg(rec, req)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "tenant_shutdown_incomplete") {
t.Fatalf("unsafe deletion result: %d %s", rec.Code, rec.Body.String())
}
if called {
t.Fatal("offboarding callback ran after incomplete shutdown")
}
if _, err := os.Stat(filepath.Join(orgPath, "org.json")); err != nil {
t.Fatalf("organisation data removed: %v", err)
}
if got, ok := mtm.PeekMonitor("retained"); !ok || got != monitor {
t.Fatal("failed writer owner lost")
}
if _, err := mtm.GetMonitor("retained"); err == nil {
t.Fatal("new monitor can write into retained shutdown path")
}
// Another tenant remains usable: the failed writer is not a global hold.
if err := persistence.SaveOrganization(&models.Organization{ID: "other"}); err != nil {
t.Fatal(err)
}
if _, err := persistence.GetPersistence("other"); err != nil {
t.Fatal(err)
}
}
+15
View File
@@ -57,6 +57,21 @@ func newEndpointRateLimitConfig() *EndpointRateLimitConfig {
}
}
// Stop joins only the workers owned by this endpoint configuration. A Router
// must not stop the package-global configuration or another Router's budgets.
func (cfg *EndpointRateLimitConfig) Stop() {
if cfg == nil {
return
}
for _, limiter := range []*RateLimiter{
cfg.AuthEndpoints, cfg.ConfigEndpoints, cfg.ExportEndpoints,
cfg.RecoveryEndpoints, cfg.UpdateEndpoints, cfg.WebSocketEndpoints,
cfg.GeneralAPI, cfg.PublicEndpoints,
} {
limiter.Stop()
}
}
func generalAPIRateLimit() int {
if os.Getenv("PULSE_DEV") != "true" {
return defaultGeneralAPIRateLimit
+15 -5
View File
@@ -19,6 +19,8 @@ type RateLimiter struct {
limit int
window time.Duration
stopCleanup chan struct{}
cleanupDone chan struct{}
stopOnce sync.Once
}
// NewRateLimiter creates a rate limiter that allows limit requests per window duration.
@@ -44,10 +46,12 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
limit: limit,
window: window,
stopCleanup: make(chan struct{}),
cleanupDone: make(chan struct{}),
}
// Clean up old entries periodically
go func() {
defer close(rl.cleanupDone)
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
@@ -64,13 +68,19 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return rl
}
// Stop stops the cleanup routine
// Stop stops and joins the cleanup routine. Concurrent owners may call it
// repeatedly without racing a channel close. Request budgets remain intact.
func (rl *RateLimiter) Stop() {
select {
case <-rl.stopCleanup:
if rl == nil {
return
default:
close(rl.stopCleanup)
}
rl.stopOnce.Do(func() {
if rl.stopCleanup != nil {
close(rl.stopCleanup)
}
})
if rl.cleanupDone != nil {
<-rl.cleanupDone
}
}
+66 -32
View File
@@ -132,6 +132,7 @@ type Router struct {
handoffExchangeRateLimiter *RateLimiter
bootstrapTokenValidationLimiter *RateLimiter
tenantRateLimiter *TenantRateLimiter
endpointRateLimitConfig *EndpointRateLimitConfig
persistence *config.ConfigPersistence
multiTenant *config.MultiTenantPersistence
oidcMu sync.Mutex
@@ -166,6 +167,9 @@ type Router struct {
lifecycleCancel context.CancelFunc
lifecycleWG sync.WaitGroup
backgroundWorkersOnce sync.Once
backgroundShutdownOnce sync.Once
lifecycleMu sync.Mutex
lifecycleStopped bool
hostedMode bool
stripeWebhookHandlers *StripeWebhookHandlers
patrolLifecycleMu sync.Mutex
@@ -234,6 +238,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit
wsHub: wsHub,
reloadFunc: reloadFunc,
updateManager: updateManager,
endpointRateLimitConfig: newEndpointRateLimitConfig(),
updateHistory: updateHistory,
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
downloadLimiter: NewRateLimiter(60, 1*time.Minute), // downloads/installers per minute per IP
@@ -350,7 +355,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit
// Auth context middleware extracts user/token info BEFORE tenant middleware
handler = AuthContextMiddleware(cfg, r.mtMonitor, handler)
handler = UniversalRateLimitMiddlewareWithConfig(newEndpointRateLimitConfig(), handler)
handler = UniversalRateLimitMiddlewareWithConfig(r.endpointRateLimitConfig, handler)
r.wrapped = handler
return r
}
@@ -1562,6 +1567,11 @@ func (r *Router) startLifecycleWorker(worker func()) {
if r == nil || worker == nil {
return
}
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
if r.lifecycleStopped {
return
}
r.lifecycleWG.Add(1)
go func() {
defer r.lifecycleWG.Done()
@@ -2978,40 +2988,64 @@ func (r *Router) ShutdownRBAC() {
}
}
// ShutdownBackgroundWorkers seals worker admission and joins the workers
// created by this Router. Call it after HTTP admission is stopped, before
// closing the monitor and removing its data directory. It does not close
// resource or RBAC stores; their existing owner shutdown methods still apply.
func (r *Router) ShutdownBackgroundWorkers() {
r.shutdownBackgroundWorkers()
}
func (r *Router) shutdownBackgroundWorkers() {
if r.lifecycleCancel != nil {
r.lifecycleCancel()
if r == nil {
return
}
if r.updateManager != nil {
r.updateManager.Close()
}
if r.sessionStore != nil {
r.sessionStore.Shutdown()
}
if r.csrfStore != nil {
r.csrfStore.Shutdown()
}
if r.recoveryTokenStore != nil {
r.recoveryTokenStore.Shutdown()
}
if r.aiSettingsHandler != nil {
r.aiSettingsHandler.StopServices()
}
if r.aiHandler != nil {
r.aiHandler.clearApprovalStore()
}
if r.trueNASPoller != nil {
r.trueNASPoller.Stop()
}
if r.vmwarePoller != nil {
r.vmwarePoller.Stop()
}
if r.deployStore != nil {
if err := r.deployStore.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close deploy store")
r.backgroundShutdownOnce.Do(func() {
r.lifecycleMu.Lock()
r.lifecycleStopped = true
if r.lifecycleCancel != nil {
r.lifecycleCancel()
}
}
r.lifecycleWG.Wait()
r.lifecycleMu.Unlock()
for _, limiter := range []*RateLimiter{
r.exportLimiter, r.downloadLimiter, r.signupRateLimiter,
r.handoffExchangeRateLimiter, r.bootstrapTokenValidationLimiter,
} {
limiter.Stop()
}
r.endpointRateLimitConfig.Stop()
r.tenantRateLimiter.Stop()
if r.updateManager != nil {
r.updateManager.Close()
}
if r.sessionStore != nil {
r.sessionStore.Shutdown()
}
if r.csrfStore != nil {
r.csrfStore.Shutdown()
}
if r.recoveryTokenStore != nil {
r.recoveryTokenStore.Shutdown()
}
if r.aiSettingsHandler != nil {
r.aiSettingsHandler.StopServices()
}
if r.aiHandler != nil {
r.aiHandler.clearApprovalStore()
}
if r.trueNASPoller != nil {
r.trueNASPoller.Stop()
}
if r.vmwarePoller != nil {
r.vmwarePoller.Stop()
}
if r.deployStore != nil {
if err := r.deployStore.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close deploy store")
}
}
r.lifecycleWG.Wait()
})
}
// StartAIChat starts the AI chat service
+8
View File
@@ -201,9 +201,17 @@ func newIntegrationServerWithRuntimeMode(
return nil
}, version)
// Register constructor cleanup before a listener or request assertion can
// fail. The fixture owns the same lifecycle used by the running server.
t.Cleanup(func() {
router.ShutdownBackgroundWorkers()
router.ShutdownResourceStores()
router.ShutdownRBAC()
})
srv := newIPv4HTTPServer(t, router.Handler())
t.Cleanup(func() {
srv.Close()
router.ShutdownBackgroundWorkers()
if monitor != nil {
monitor.StopDiscoveryService()
monitor.Stop()
@@ -0,0 +1,143 @@
package api
import (
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func routerOwnedLimiters(r *Router) []*RateLimiter {
limiters := []*RateLimiter{r.exportLimiter, r.downloadLimiter, r.signupRateLimiter,
r.handoffExchangeRateLimiter, r.bootstrapTokenValidationLimiter}
limiters = append(limiters, rateLimitFixtureWorkers(r.endpointRateLimitConfig)...)
if r.tenantRateLimiter != nil {
limiters = append(limiters, r.tenantRateLimiter.limiter)
}
return limiters
}
func requireLimiterJoined(t *testing.T, limiter *RateLimiter) {
t.Helper()
select {
case <-limiter.cleanupDone:
default:
t.Error("Stop returned with a cleanup worker still running")
}
}
func TestRateLimiterConcurrentStopJoinsWithoutResettingBudget(t *testing.T) {
limiter := NewRateLimiter(1, time.Minute)
t.Cleanup(limiter.Stop)
if !limiter.Allow("192.0.2.1") || limiter.Allow("192.0.2.1") {
t.Fatal("fixture did not consume its budget")
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
limiter.Stop()
limiter.Allow("192.0.2.2") // In-flight requests retain normal accounting.
}()
}
wg.Wait()
requireLimiterJoined(t, limiter)
if limiter.Allow("192.0.2.1") {
t.Error("stopping cleanup reset an exhausted request budget")
}
(*RateLimiter)(nil).Stop()
(&RateLimiter{}).Stop()
}
func TestRouterShutdownJoinsEveryOwnedLimiter(t *testing.T) {
for _, hosted := range []string{"false", "true"} {
t.Run(hosted, func(t *testing.T) {
t.Setenv("PULSE_HOSTED_MODE", hosted)
for i := 0; i < 3; i++ {
r := NewRouter(&config.Config{DataPath: t.TempDir()}, nil, nil, nil, nil, "test")
cleanupTestRouter(t, r)
owned := routerOwnedLimiters(r)
want := 13
if hosted == "true" {
want++
}
if len(owned) != want {
t.Fatalf("owned limiters = %d, want %d", len(owned), want)
}
r.ShutdownBackgroundWorkers()
r.ShutdownBackgroundWorkers()
for _, limiter := range owned {
requireLimiterJoined(t, limiter)
}
}
})
}
}
func TestRouterShutdownPreservesOtherRouterAndGlobalBudgets(t *testing.T) {
isolateRateLimitFixture(t)
InitializeRateLimiters()
global := globalRateLimitConfig
first := NewRouter(&config.Config{DataPath: t.TempDir()}, nil, nil, nil, nil, "test")
cleanupTestRouter(t, first)
second := NewRouter(&config.Config{DataPath: t.TempDir()}, nil, nil, nil, nil, "test")
cleanupTestRouter(t, second)
login := getRateLimiterForEndpoint(second.endpointRateLimitConfig, "/api/login", http.MethodPost)
for i := 0; i < login.limit; i++ {
if !login.Allow("192.0.2.1") {
t.Fatal("other router budget exhausted early")
}
}
first.ShutdownBackgroundWorkers()
if login.Allow("192.0.2.1") {
t.Error("another router's shutdown reset login enforcement")
}
for _, limiter := range append(routerOwnedLimiters(second), rateLimitFixtureWorkers(global)...) {
select {
case <-limiter.stopCleanup:
t.Error("shutdown stopped a limiter owned by another router/global configuration")
default:
}
}
if !second.exportLimiter.Allow("192.0.2.2") {
t.Error("other router no longer admits its independent export budget")
}
}
func TestRouterShutdownSealsAndJoinsConcurrentWorkerAdmission(t *testing.T) {
r := NewRouter(&config.Config{DataPath: t.TempDir()}, nil, nil, nil, nil, "test")
cleanupTestRouter(t, r)
var started, exited atomic.Int64
var callers sync.WaitGroup
for i := 0; i < 32; i++ {
callers.Add(2)
go func() {
defer callers.Done()
r.startLifecycleWorker(func() {
started.Add(1)
<-r.lifecycleCtx.Done()
exited.Add(1)
})
}()
go func() {
defer callers.Done()
r.ShutdownBackgroundWorkers()
}()
}
callers.Wait()
if started.Load() != exited.Load() {
t.Fatalf("shutdown returned before joining admitted workers: started %d, exited %d", started.Load(), exited.Load())
}
r.startLifecycleWorker(func() { t.Error("worker admitted after shutdown") })
r.StartBackgroundWorkers() // Late application start cannot reopen admission.
r.ShutdownBackgroundWorkers()
for _, limiter := range routerOwnedLimiters(r) {
requireLimiterJoined(t, limiter)
}
(*Router)(nil).ShutdownBackgroundWorkers()
(&Router{}).ShutdownBackgroundWorkers()
}
+221 -42
View File
@@ -2,11 +2,13 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
@@ -31,49 +33,202 @@ type GuestMetadataStore struct {
dataPath string
fs FileSystem
// inflight tracks background Set calls started by SetAsync so shutdown can
// wait for them. Callers used to spawn their own detached goroutines, which
// meant a write could land after the monitor stopped and after the data
// directory was being torn down, leaving a stray guest_metadata.json.tmp.
inflight sync.WaitGroup
// writeMu serializes disk snapshots with synchronous mutations. Background
// I/O never holds mu, so a burst can accumulate in one follow-up snapshot.
// Always acquire writeMu before mu when both locks are needed.
writeMu sync.Mutex
revision uint64
attemptedRevision uint64
lastWriteErr error
writerDone chan struct{}
closed atomic.Bool
closeMu sync.Mutex
closeAttempt *guestMetadataCloseAttempt
}
// SetAsync persists metadata without blocking the caller, while keeping the
// write owned by the store so WaitForPendingWrites can drain it on shutdown.
func (s *GuestMetadataStore) SetAsync(guestID string, meta *GuestMetadata) {
type guestMetadataCloseAttempt struct {
done chan struct{}
err error
}
var ErrGuestMetadataStoreClosed = errors.New("guest metadata store is closed")
// SetAsync admits a detached replacement before returning. One store-owned
// writer persists the newest complete snapshot, coalescing changes that arrive
// during I/O. It does not start a goroutine or rewrite the file per guest.
func (s *GuestMetadataStore) SetAsync(guestID string, meta *GuestMetadata) error {
if s == nil {
return nil
}
if meta == nil {
return fmt.Errorf("metadata cannot be nil")
}
clone := cloneGuestMetadata(meta)
clone.ID = guestID
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
s.metadata[guestID] = clone
s.queueSaveLocked()
return nil
}
// RememberIdentity merges only monitor-owned identity fields at admission.
// A background name/type update must never restore an old URL, tag or note
// over a concurrent operator edit. OCI classification cannot be downgraded by
// a later poll lacking the classification evidence.
func (s *GuestMetadataStore) RememberIdentity(guestID, name, guestType string) error {
if s == nil {
return nil
}
guestType = strings.TrimSpace(guestType)
if guestType == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
meta := cloneGuestMetadata(s.metadata[guestID])
if meta == nil {
meta = &GuestMetadata{ID: guestID, Tags: []string{}}
}
if meta.LastKnownType == "oci" {
guestType = "oci"
}
if meta.LastKnownName == name && meta.LastKnownType == guestType && s.lastWriteErr == nil {
return nil
}
meta.LastKnownName = name
meta.LastKnownType = guestType
s.metadata[guestID] = meta
s.queueSaveLocked()
return nil
}
func (s *GuestMetadataStore) queueSaveLocked() {
s.revision++
if s.writerDone != nil {
return
}
s.inflight.Add(1)
go func() {
defer s.inflight.Done()
if err := s.Set(guestID, meta); err != nil {
log.Error().Err(err).Str("guestID", guestID).Msg("failed to persist guest metadata")
}
}()
s.writerDone = make(chan struct{})
go s.writePendingSnapshots()
}
// WaitForPendingWrites blocks until background writes finish or the timeout
// elapses. It reports whether the store drained; a false result means a write
// may still be in flight and the data directory is not safe to remove.
func (s *GuestMetadataStore) writePendingSnapshots() {
for {
s.writeMu.Lock()
s.mu.Lock()
if s.attemptedRevision == s.revision {
close(s.writerDone)
s.writerDone = nil
s.mu.Unlock()
s.writeMu.Unlock()
return
}
revision := s.revision
data, err := json.Marshal(s.metadata)
s.mu.Unlock()
if err == nil {
err = persistMetadata(s.fs, s.dataPath, "guest_metadata.json", data)
}
s.mu.Lock()
s.attemptedRevision = revision
s.lastWriteErr = err
s.mu.Unlock()
s.writeMu.Unlock()
if err != nil {
// No unbounded automatic retry on a failing filesystem. A later
// admitted mutation (or ordinary identity poll) can try again.
log.Error().Err(err).Msg("failed to persist guest metadata snapshot")
}
}
}
// WaitForPendingWrites reports quiescence, not persistence success. Producers
// must already be stopped if the caller plans to remove the data directory;
// Close seals admission as well. A timeout never makes teardown safe.
func (s *GuestMetadataStore) WaitForPendingWrites(timeout time.Duration) bool {
if s == nil {
return true
}
done := make(chan struct{})
go func() {
s.inflight.Wait()
close(done)
}()
s.mu.RLock()
done := s.writerDone
s.mu.RUnlock()
if done == nil {
return true
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return true
case <-time.After(timeout):
case <-timer.C:
log.Warn().Dur("timeout", timeout).Msg("timed out waiting for guest metadata writes to drain")
return false
}
}
// Close rejects further mutations, drains the owned writer within the supplied
// budget, and reports any final persistence error. Failure keeps the writer
// owned and must stop tenant-directory removal; it is not a successful stop.
func (s *GuestMetadataStore) Close(timeout time.Duration) error {
if s == nil {
return nil
}
// Seal before waiting for a synchronous mutation's I/O-held memory lock.
// One owned close observer, not one goroutine per waiter, covers both that
// mutation and the background writer within the caller's unchanged budget.
s.closed.Store(true)
s.closeMu.Lock()
attempt := s.closeAttempt
retry := false
if attempt != nil {
select {
case <-attempt.done:
// A later explicit shutdown/offboarding attempt may flush retained
// state after the filesystem is repaired. Never reopen admission,
// retry an in-flight write or spin-retry a failed close on its own.
if attempt.err != nil {
attempt = nil
retry = true
}
default:
}
}
if attempt == nil {
attempt = &guestMetadataCloseAttempt{done: make(chan struct{})}
s.closeAttempt = attempt
go func() {
s.mu.Lock()
if retry && s.lastWriteErr != nil && s.writerDone == nil {
s.queueSaveLocked()
}
done := s.writerDone
s.mu.Unlock()
if done != nil {
<-done
}
s.mu.RLock()
attempt.err = s.lastWriteErr
s.mu.RUnlock()
close(attempt.done)
}()
}
s.closeMu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-attempt.done:
return attempt.err
case <-timer.C:
return fmt.Errorf("guest metadata writes did not drain within %s", timeout)
}
}
func cloneGuestMetadata(meta *GuestMetadata) *GuestMetadata {
if meta == nil {
return nil
@@ -113,6 +268,16 @@ func NewGuestMetadataStore(dataPath string, fs FileSystem) *GuestMetadataStore {
// Load reads metadata from disk
func (s *GuestMetadataStore) Load() error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
if s.revision != s.attemptedRevision || s.lastWriteErr != nil {
return fmt.Errorf("cannot reload guest metadata with unpersisted changes")
}
filePath := filepath.Join(s.dataPath, "guest_metadata.json")
log.Debug().Str("path", filePath).Msg("Loading guest metadata from disk")
@@ -127,9 +292,6 @@ func (s *GuestMetadataStore) Load() error {
return fmt.Errorf("failed to read metadata file: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if err := json.Unmarshal(data, &s.metadata); err != nil {
return fmt.Errorf("failed to unmarshal metadata: %w", err)
}
@@ -138,24 +300,16 @@ func (s *GuestMetadataStore) Load() error {
return nil
}
// save writes metadata to disk (must be called with lock held)
// save writes a synchronous mutation (writeMu and mu must both be held).
func (s *GuestMetadataStore) save() error {
filePath := filepath.Join(s.dataPath, "guest_metadata.json")
log.Debug().Str("path", filePath).Msg("Saving guest metadata to disk")
s.revision++
data, err := json.Marshal(s.metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
if err == nil {
err = persistMetadata(s.fs, s.dataPath, "guest_metadata.json", data)
}
if err := persistMetadata(s.fs, s.dataPath, "guest_metadata.json", data); err != nil {
return err
}
log.Debug().Str("path", filePath).Int("entries", len(s.metadata)).Msg("Guest metadata saved successfully")
return nil
s.attemptedRevision = s.revision
s.lastWriteErr = err
return err
}
// Get retrieves metadata for a guest
@@ -188,8 +342,13 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri
// Helper to migrate a legacy ID to the new format
migrate := func(legacyID string) *GuestMetadata {
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return nil
}
if current := s.metadata[guestID]; current != nil {
return cloneGuestMetadata(current)
@@ -293,8 +452,13 @@ func (s *GuestMetadataStore) GetAll() map[string]*GuestMetadata {
// Set updates or creates metadata for a guest
func (s *GuestMetadataStore) Set(guestID string, meta *GuestMetadata) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
if meta == nil {
return fmt.Errorf("metadata cannot be nil")
@@ -310,8 +474,13 @@ func (s *GuestMetadataStore) Set(guestID string, meta *GuestMetadata) error {
// Delete removes metadata for a guest
func (s *GuestMetadataStore) Delete(guestID string) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
delete(s.metadata, guestID)
@@ -321,8 +490,13 @@ func (s *GuestMetadataStore) Delete(guestID string) error {
// ReplaceAll replaces all metadata entries and persists them to disk.
func (s *GuestMetadataStore) ReplaceAll(metadata map[string]*GuestMetadata) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
s.metadata = make(map[string]*GuestMetadata)
@@ -354,8 +528,13 @@ func (s *GuestMetadataStore) UpdateAll(
return nil
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed.Load() {
return ErrGuestMetadataStoreClosed
}
working := make(map[string]*GuestMetadata, len(s.metadata))
for id, meta := range s.metadata {
@@ -0,0 +1,396 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
func guestWriterTestStore(t *testing.T) (*GuestMetadataStore, *controlledGuestWriterFS) {
t.Helper()
fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})}
store := NewGuestMetadataStore(t.TempDir(), fs)
var releaseOnce sync.Once
t.Cleanup(func() {
releaseOnce.Do(func() {
select {
case <-fs.release:
default:
close(fs.release)
}
})
if !store.WaitForPendingWrites(time.Second) {
t.Error("writer did not exit during cleanup")
}
})
if err := store.SetAsync("first", &GuestMetadata{LastKnownName: "first"}); err != nil {
t.Fatal(err)
}
select {
case <-fs.entered:
case <-time.After(time.Second):
t.Fatal("writer not started")
}
return store, fs
}
func capturedGuestSnapshot(t *testing.T, fs *controlledGuestWriterFS) map[string]*GuestMetadata {
t.Helper()
fs.mu.Lock()
data := append([]byte(nil), fs.data...)
fs.mu.Unlock()
var result map[string]*GuestMetadata
if err := json.Unmarshal(data, &result); err != nil {
t.Fatal(err)
}
return result
}
func TestGuestMetadataWriterOrderedReplacementsAndReadsDuringIO(t *testing.T) {
store, fs := guestWriterTestStore(t)
for i := 0; i < 100; i++ {
if err := store.SetAsync("same", &GuestMetadata{LastKnownName: fmt.Sprint(i)}); err != nil {
t.Fatal(err)
}
}
// This is an admission-state read, not a persistence-success assertion.
read := make(chan *GuestMetadata, 1)
go func() { read <- store.Get("same") }()
select {
case got := <-read:
if got.LastKnownName != "99" {
t.Fatalf("wrong admission order: %+v", got)
}
case <-time.After(time.Second):
t.Fatal("disk I/O blocked metadata reads")
}
close(fs.release)
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
if got := capturedGuestSnapshot(t, fs)["same"]; got.LastKnownName != "99" {
t.Fatalf("older update persisted last: %+v", got)
}
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.writes != 2 {
t.Fatalf("100 replacements during one blocked write caused %d writes", fs.writes)
}
}
func TestGuestMetadataWriterSynchronousMutationsCannotBeOverwritten(t *testing.T) {
for _, operation := range []string{"set", "delete", "replace", "update", "migration"} {
t.Run(operation, func(t *testing.T) {
store, fs := guestWriterTestStore(t)
id := "pve:node:100"
if err := store.SetAsync(id, &GuestMetadata{LastKnownName: "queued"}); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
switch operation {
case "set":
done <- store.Set(id, &GuestMetadata{LastKnownName: "operator", Notes: []string{"new"}})
case "delete":
done <- store.Delete(id)
case "replace":
done <- store.ReplaceAll(map[string]*GuestMetadata{"replacement": {LastKnownName: "replacement"}})
case "update":
done <- store.UpdateAll(func(m map[string]*GuestMetadata) bool { m[id].Notes = []string{"edited"}; return true })
case "migration":
if store.GetWithLegacyMigration("pve:other:100", "pve", "other", 100) == nil {
done <- errors.New("migration missing")
} else {
done <- nil
}
}
}()
close(fs.release)
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("synchronous mutation blocked")
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
saved := capturedGuestSnapshot(t, fs)
if !reflect.DeepEqual(saved, store.GetAll()) {
t.Fatalf("disk snapshot differs after %s: saved=%+v memory=%+v", operation, saved, store.GetAll())
}
switch operation {
case "set":
if saved[id].LastKnownName != "operator" || saved[id].Notes[0] != "new" {
t.Fatal("operator replacement lost")
}
case "delete":
if saved[id] != nil {
t.Fatal("deleted entry resurrected")
}
case "replace":
if len(saved) != 1 || saved["replacement"] == nil {
t.Fatal("replace resurrected old entries")
}
case "update":
if saved[id].Notes[0] != "edited" {
t.Fatal("update lost")
}
case "migration":
if saved[id] != nil || saved["pve:other:100"] == nil {
t.Fatal("old node resurrected")
}
}
})
}
}
func TestGuestMetadataWriterIdentityPreservesOperatorFieldsAndOCI(t *testing.T) {
store := NewGuestMetadataStore(t.TempDir(), nil)
original := &GuestMetadata{CustomURL: "https://operator.example", Description: "operator", Tags: []string{"tag"}, Notes: []string{"note"}, LastKnownName: "old", LastKnownType: "oci"}
if err := store.Set("pve:node:100", original); err != nil {
t.Fatal(err)
}
if err := store.RememberIdentity("pve:node:100", "new", " lxc "); err != nil {
t.Fatal(err)
}
got := store.Get("pve:node:100")
if got.LastKnownName != "new" || got.LastKnownType != "oci" {
t.Fatalf("identity/OCI lost: %+v", got)
}
original.ID = "pve:node:100"
original.LastKnownName = "new"
if !reflect.DeepEqual(got, original) {
t.Fatalf("identity changed operator data: %+v", got)
}
if !store.WaitForPendingWrites(time.Second) {
t.Fatal("identity not drained")
}
before := store.revision
if err := store.RememberIdentity("pve:node:100", "new", "oci"); err != nil {
t.Fatal(err)
}
if err := store.RememberIdentity("pve:node:100", "ignored", " "); err != nil {
t.Fatal(err)
}
if store.revision != before {
t.Fatal("unchanged/empty type triggered a write")
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
reloaded := NewGuestMetadataStore(store.dataPath, nil)
if !reflect.DeepEqual(reloaded.Get("pve:node:100"), got) {
t.Fatal("identity/operator fields not persisted")
}
}
func TestGuestMetadataWriterCloseBoundsBlockedAsyncAndSyncIO(t *testing.T) {
for _, synchronous := range []bool{false, true} {
t.Run(fmt.Sprint(synchronous), func(t *testing.T) {
fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})}
store := NewGuestMetadataStore(t.TempDir(), fs)
done := make(chan error, 1)
if synchronous {
go func() { done <- store.Set("first", &GuestMetadata{LastKnownName: "first"}) }()
} else {
done <- store.SetAsync("first", &GuestMetadata{LastKnownName: "first"})
}
select {
case <-fs.entered:
case <-time.After(time.Second):
t.Fatal("write did not start")
}
t.Cleanup(func() {
select {
case <-fs.release:
default:
close(fs.release)
}
_ = store.Close(time.Second)
})
start := time.Now()
if err := store.Close(10 * time.Millisecond); err == nil {
t.Fatal("blocked writer reported close success")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("close exceeded predeclared bounded budget: %s", elapsed)
}
close(fs.release)
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("write still running")
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
for _, mutation := range []func() error{
func() error { return store.SetAsync("later", &GuestMetadata{}) },
func() error { return store.RememberIdentity("later", "later", "qemu") },
func() error { return store.Set("later", &GuestMetadata{}) },
func() error { return store.Delete("first") },
func() error { return store.ReplaceAll(nil) },
func() error { return store.UpdateAll(func(map[string]*GuestMetadata) bool { return true }) },
store.Load,
} {
if err := mutation(); !errors.Is(err, ErrGuestMetadataStoreClosed) {
t.Fatalf("closed admission accepted: %v", err)
}
}
if store.GetWithLegacyMigration("node:100", "pve", "node", 100) != nil {
t.Fatal("closed store migrated metadata")
}
if capturedGuestSnapshot(t, fs)["later"] != nil {
t.Fatal("post-close write")
}
})
}
}
type guestWriterFaultFS struct {
FileSystem
failWrite atomic.Bool
failRename atomic.Bool
writes atomic.Int32
}
func (f *guestWriterFaultFS) WriteFile(path string, data []byte, perm os.FileMode) error {
f.writes.Add(1)
if f.failWrite.Load() {
return errors.New("controlled write failure")
}
return f.FileSystem.WriteFile(path, data, perm)
}
func (f *guestWriterFaultFS) Rename(a, b string) error {
if f.failRename.Load() {
return errors.New("controlled rename failure")
}
return f.FileSystem.Rename(a, b)
}
func TestGuestMetadataWriterFailuresAreOwnedWithoutSpinRetry(t *testing.T) {
for _, phase := range []string{"write", "rename"} {
t.Run(phase, func(t *testing.T) {
fs := &guestWriterFaultFS{FileSystem: defaultFileSystem{}}
store := NewGuestMetadataStore(t.TempDir(), fs)
if err := store.Set("key", &GuestMetadata{LastKnownName: "original"}); err != nil {
t.Fatal(err)
}
fs.failWrite.Store(phase == "write")
fs.failRename.Store(phase == "rename")
if err := store.RememberIdentity("key", "changed", "qemu"); err != nil {
t.Fatal(err)
}
if !store.WaitForPendingWrites(time.Second) {
t.Fatal("failed attempt not quiescent")
}
if fs.writes.Load() != 2 {
t.Fatalf("failure retried automatically: %d", fs.writes.Load())
}
if got := NewGuestMetadataStore(store.dataPath, nil).Get("key"); got.LastKnownName != "original" {
t.Fatal("failure replaced good disk snapshot")
}
fs.failWrite.Store(false)
fs.failRename.Store(false)
// A later ordinary poll retries the unpersisted identity, not a tight loop.
if err := store.RememberIdentity("key", "changed", "qemu"); err != nil {
t.Fatal(err)
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
if fs.writes.Load() != 3 {
t.Fatalf("explicit recovery writes=%d", fs.writes.Load())
}
if got := NewGuestMetadataStore(store.dataPath, nil).Get("key"); got.LastKnownName != "changed" {
t.Fatal("recovery not persisted")
}
fs.failWrite.Store(true)
failed := NewGuestMetadataStore(t.TempDir(), fs)
if err := failed.SetAsync("lost", &GuestMetadata{}); err != nil {
t.Fatal(err)
}
if err := failed.Close(time.Second); err == nil {
t.Fatal("failed final write reported durable close")
}
})
}
}
func TestGuestMetadataWriterRealFilesystemTenantIsolation(t *testing.T) {
root := t.TempDir()
a := NewGuestMetadataStore(filepath.Join(root, "orgs", "a"), nil)
b := NewGuestMetadataStore(filepath.Join(root, "orgs", "b"), nil)
for i := 0; i < 100; i++ {
id := fmt.Sprintf("pve:node:%d", i)
if err := a.RememberIdentity(id, "a", "qemu"); err != nil {
t.Fatal(err)
}
if err := b.RememberIdentity(id, "b", "lxc"); err != nil {
t.Fatal(err)
}
}
for _, store := range []*GuestMetadataStore{a, b} {
if err := store.Close(10 * time.Second); err != nil {
t.Fatal(err)
}
reloaded := NewGuestMetadataStore(store.dataPath, nil)
if !reflect.DeepEqual(reloaded.GetAll(), store.GetAll()) {
t.Fatal("real filesystem lost entries")
}
info, err := os.Stat(filepath.Join(store.dataPath, "guest_metadata.json"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("unsafe file permissions: %v", info.Mode())
}
if _, err := os.Stat(filepath.Join(store.dataPath, "guest_metadata.json.tmp")); !os.IsNotExist(err) {
t.Fatalf("temporary file survived close: %v", err)
}
}
if a.Get("pve:node:1").LastKnownName == b.Get("pve:node:1").LastKnownName {
t.Fatal("tenants share identity")
}
}
// Retention after a failed close is not a permanent offboarding dead end.
// A later explicit close may flush retained state after the filesystem recovers,
// without accepting new mutations or overlapping the original writer.
func TestGuestMetadataWriterExplicitCloseRetryPersistsRetainedState(t *testing.T) {
fs := &guestWriterFaultFS{FileSystem: defaultFileSystem{}}
fs.failWrite.Store(true)
store := NewGuestMetadataStore(t.TempDir(), fs)
if err := store.SetAsync("retained", &GuestMetadata{LastKnownName: "retained"}); err != nil {
t.Fatal(err)
}
if err := store.Close(time.Second); err == nil {
t.Fatal("first failed close reported success")
}
if fs.writes.Load() != 1 {
t.Fatalf("failed close retried without an explicit later call: %d", fs.writes.Load())
}
if err := store.SetAsync("late", &GuestMetadata{}); !errors.Is(err, ErrGuestMetadataStoreClosed) {
t.Fatalf("failed close reopened admission: %v", err)
}
fs.failWrite.Store(false)
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
if fs.writes.Load() != 2 {
t.Fatalf("explicit recovery or successful idempotent close wrote unexpected snapshots: %d", fs.writes.Load())
}
reloaded := NewGuestMetadataStore(store.dataPath, nil)
if reloaded.Get("retained") == nil || reloaded.Get("late") != nil {
t.Fatal("explicit close recovery lost retained state or wrote a late admission")
}
}
@@ -0,0 +1,100 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
"testing"
"time"
)
type controlledGuestWriterFS struct {
FileSystem
mu sync.Mutex
writes int
data []byte
entered chan struct{}
release chan struct{}
}
func (f *controlledGuestWriterFS) MkdirAll(string, os.FileMode) error { return nil }
func (f *controlledGuestWriterFS) Rename(string, string) error { return nil }
func (f *controlledGuestWriterFS) WriteFile(_ string, data []byte, _ os.FileMode) error {
f.mu.Lock()
f.writes++
first := f.writes == 1
f.mu.Unlock()
if first {
close(f.entered)
<-f.release
}
f.mu.Lock()
f.data = append([]byte(nil), data...)
f.mu.Unlock()
return nil
}
func TestGuestWriterControlledBurst(t *testing.T) {
for _, count := range []int{25, 100} {
t.Run(fmt.Sprint(count), func(t *testing.T) {
fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})}
store := NewGuestMetadataStore(t.TempDir(), fs)
store.SetAsync("first", &GuestMetadata{LastKnownName: "first"})
select {
case <-fs.entered:
case <-time.After(time.Second):
t.Fatal("first write did not start")
}
for i := 0; i < count; i++ {
store.SetAsync(fmt.Sprint(i), &GuestMetadata{LastKnownName: fmt.Sprint(i)})
}
close(fs.release)
if !store.WaitForPendingWrites(10 * time.Second) {
t.Fatal("drain failed")
}
fs.mu.Lock()
writes := fs.writes
data := append([]byte(nil), fs.data...)
fs.mu.Unlock()
t.Logf("admitted=%d complete-file-writes=%d", count+1, writes)
if writes > 2 {
t.Errorf("one blocked snapshot plus one complete follow-up needed, got %d", writes)
}
var saved map[string]*GuestMetadata
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatal(err)
}
if len(saved) != count+1 {
t.Fatalf("lost guests: got %d want %d", len(saved), count+1)
}
for i := 0; i < count; i++ {
if saved[fmt.Sprint(i)].LastKnownName != fmt.Sprint(i) {
t.Errorf("wrong guest %d", i)
}
}
})
}
}
func TestGuestWriterCopiesAtAdmission(t *testing.T) {
fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})}
store := NewGuestMetadataStore(t.TempDir(), fs)
store.SetAsync("first", &GuestMetadata{LastKnownName: "first"})
select {
case <-fs.entered:
case <-time.After(time.Second):
t.Fatal("first write did not start")
}
meta := &GuestMetadata{LastKnownName: "original", Tags: []string{"original"}, Notes: []string{"original"}}
store.SetAsync("second", meta)
meta.LastKnownName = "mutated"
meta.Tags[0] = "mutated"
meta.Notes[0] = "mutated"
close(fs.release)
if !store.WaitForPendingWrites(10 * time.Second) {
t.Fatal("drain failed")
}
got := store.Get("second")
if got.LastKnownName != "original" || got.Tags[0] != "original" || got.Notes[0] != "original" {
t.Fatalf("caller mutation reached stored metadata: %+v", got)
}
}
@@ -0,0 +1,320 @@
package monitoring
import (
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func projectionFixtureGraph(t testing.TB) mock.FixtureGraph {
t.Helper()
previousEnabled, previousConfig := mock.IsMockEnabled(), mock.GetConfig()
t.Cleanup(func() {
mustSetMockEnabled(t, false)
mock.SetMockConfig(previousConfig)
mustSetMockEnabled(t, previousEnabled)
})
mustSetMockEnabled(t, false)
cfg := mock.DefaultConfig
cfg.UpdateInterval = 5 * time.Minute
mock.SetMockConfig(cfg)
mustSetMockEnabled(t, true)
graph := mock.CurrentFixtureGraph()
mustSetMockEnabled(t, false)
return graph
}
func projectionFixtureRegistry(graph mock.FixtureGraph) *unifiedresources.ResourceRegistry {
rr := unifiedresources.NewRegistry(nil)
rr.IngestSnapshot(graph.State)
for _, source := range []unifiedresources.DataSource{unifiedresources.SourceTrueNAS, unifiedresources.SourceVMware, unifiedresources.SourceAvailability} {
rr.IngestRecords(source, graph.SupplementalRecords(source))
}
return rr
}
// The original full projection, including target resolution after host
// coalescing. Compare all wire fields/catalogues, not selected metric values.
func legacyFrontendProjectionForTest(m *Monitor, snapshot models.StateSnapshot, resources []unifiedresources.Resource, resolver MetricsTargetResourceStore, now time.Time) models.StateFrontend {
rows := unifiedresources.CoalescePresentationHostResources(resources)
rows = m.applyPersistedMetadataToUnifiedResources(rows)
rows = unifiedresources.AttachResourceHealth(rows, resourceHealthAlerts(snapshot.ActiveAlerts), now)
projected, catalogs := convertResourcesForBroadcastReference(rows, resolver)
out := snapshot.ToFrontend()
out.Resources = projected
out.CapabilityCatalog = catalogs.capabilities
out.PolicyCatalog = catalogs.policies
out.AISafeSummaryCatalog = catalogs.aiSafeSummaries
out.ConnectedInfrastructure = buildConnectedInfrastructure(rows, snapshot)
return out
}
func TestBroadcastMetricsSnapshotConnectedContent(t *testing.T) {
graph := projectionFixtureGraph(t)
rr := projectionFixtureRegistry(graph)
adapter := unifiedresources.NewMonitorAdapter(rr)
store := &broadcastProjectionCountingStore{MonitorAdapter: adapter}
m := &Monitor{resourceStore: store}
resources := adapter.GetAll()
if len(resources) < 1000 {
t.Fatalf("demo fixture too small: %d", len(resources))
}
sources := map[unifiedresources.DataSource]bool{}
for _, r := range resources {
for _, source := range r.Sources {
sources[source] = true
}
}
for _, source := range []unifiedresources.DataSource{unifiedresources.SourceProxmox, unifiedresources.SourceAgent, unifiedresources.SourceDocker, unifiedresources.SourcePBS, unifiedresources.SourcePMG, unifiedresources.SourceK8s, unifiedresources.SourceTrueNAS, unifiedresources.SourceVMware, unifiedresources.SourceAvailability} {
if !sources[source] {
t.Fatalf("demo omits provider %s", source)
}
}
assertEqual := func(stage string) {
t.Helper()
evaluationTime := time.Now().UTC()
before, err := json.Marshal(adapter.GetAll())
if err != nil {
t.Fatal(err)
}
want := legacyFrontendProjectionForTest(m, graph.State, adapter.GetAll(), adapter, evaluationTime)
// The parent's unified view fills a missing store watermark from the
// listed resources. Preserve that full-state header in the oracle too.
if freshness := latestUnifiedResourceLastSeen(adapter.GetAll()); !freshness.IsZero() {
want.LastUpdate = freshness.UnixMilli()
}
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(graph.State, func() time.Time { return evaluationTime })
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
gotJSON, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if string(wantJSON) != string(gotJSON) {
first := 0
for first < len(wantJSON) && first < len(gotJSON) && wantJSON[first] == gotJSON[first] {
first++
}
start := max(0, first-80)
t.Fatalf("%s: complete frontend/catalogue/infrastructure JSON differs at byte %d: want %q, got %q (bytes %d/%d)",
stage, first, wantJSON[start:min(len(wantJSON), first+160)], gotJSON[start:min(len(gotJSON), first+160)], len(wantJSON), len(gotJSON))
}
after, _ := json.Marshal(adapter.GetAll())
if string(before) != string(after) {
t.Fatalf("%s: projection changed source data", stage)
}
t.Logf("%s: %d complete resources, %d frontend rows, %d wire bytes equal legacy projection", stage, len(adapter.GetAll()), len(got.Resources), len(gotJSON))
}
assertEqual("cold-list-only")
rr.Workloads()
assertEqual("typed-views-clean")
// Same timestamps, new payload, policy and URL. No time-only invalidation.
resources = adapter.GetAll()
resources[0].Name = "changed-resource"
resources[0].Tags = []string{"customer-data"}
resources[0].CustomURL = "https://example.invalid/changed"
rr.IngestResources(resources)
assertEqual("dirty-same-time")
}
func TestBroadcastMetricsSnapshotHealthClockBoundary(t *testing.T) {
m, adapter, id := newReadStateCloneTestMonitor(t, 1)
rows := adapter.GetAll()
original := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
rows[0].LastSeen = original
rows[0].SourceStatus = map[unifiedresources.DataSource]unifiedresources.SourceStatus{
unifiedresources.SourceProxmox: {Status: "stale", LastSeen: original},
}
rr := unifiedresources.NewRegistry(nil)
rr.IngestResources(rows)
m.resourceStore = &broadcastProjectionCountingStore{MonitorAdapter: unifiedresources.NewMonitorAdapter(rr)}
for _, tc := range []struct {
age time.Duration
detail string
}{{59 * time.Second, "0m"}, {61 * time.Second, "1m"}} {
calls := 0
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(models.EmptyStateSnapshot(), func() time.Time {
calls++
return original.Add(tc.age)
})
if calls != 1 || len(got.Resources) != 1 || got.Resources[0].ID != id || got.Resources[0].LastSeen != original.UnixMilli() {
t.Fatal("health clock changed capture identity/original time or was not sampled once")
}
var health unifiedresources.ResourceHealth
if err := json.Unmarshal(got.Resources[0].Health, &health); err != nil {
t.Fatal(err)
}
if health.Verdict != unifiedresources.HealthStale || len(health.Reasons) != 1 || health.Reasons[0].Code != "telemetry_stale" || health.Reasons[0].Detail != tc.detail {
t.Fatalf("health age must advance across the minute boundary: %+v", health)
}
}
}
func TestBroadcastMetricsSnapshotOwnsTargetsBeforeLiveReplacement(t *testing.T) {
m, adapter, _ := newReadStateCloneTestMonitor(t, 4)
view := m.currentUnifiedStateView()
if view.metricsTargets == nil {
t.Fatal("broadcast did not capture a bulk resolver")
}
old := view.resources[0]
want := adapter.MetricsTargetForResource(old.ID)
adapter.PopulateFromSnapshot(models.EmptyStateSnapshot())
if got := view.metricsTargets.MetricsTargetForResource(old.ID); !reflect.DeepEqual(got, want) {
t.Fatal("captured resources were paired with a later generation's target")
}
if got := view.metricsTargets.MetricsTargetForResource("unknown"); got != nil {
t.Fatal("unknown target fell through to live data")
}
copyTarget := view.metricsTargets.MetricsTargetForResource(old.ID)
copyTarget.ResourceID = "caller-edit"
if got := view.metricsTargets.MetricsTargetForResource(old.ID); got.ResourceID == "caller-edit" {
t.Fatal("caller mutated the captured resolver")
}
}
func TestBroadcastMetricsSnapshotContinuityAndLiveOverlays(t *testing.T) {
m, adapter, _ := newReadStateCloneTestMonitor(t, 2)
now := time.Now().UTC()
m.hostContinuityStore = config.NewHostContinuityStore(t.TempDir(), nil)
entry := config.HostContinuityEntry{HostID: "absent-host", Hostname: "absent.example", MachineID: "absent-machine", LastSeen: now.Add(-time.Hour)}
if err := m.hostContinuityStore.Upsert(entry); err != nil {
t.Fatal(err)
}
view := m.unifiedStateViewWithStandaloneHostContinuity(monitorUnifiedStateView{readState: adapter})
if len(view.resources) != 3 || view.metricsTargets == nil {
t.Fatal("continuity projection omitted its resources or captured resolver")
}
var host unifiedresources.Resource
for _, r := range view.resources {
if r.Agent != nil && r.Agent.AgentID == entry.HostID {
host = r
}
if got, want := view.metricsTargets.MetricsTargetForResource(r.ID), broadcastMetricsTargetResolver(view.readState).MetricsTargetForResource(r.ID); !reflect.DeepEqual(got, want) {
t.Fatal("overlay target differs from overlay registry")
}
}
if host.ID == "" || host.Status == unifiedresources.StatusOnline || !host.LastSeen.Equal(entry.LastSeen) {
t.Fatal("continuity history was omitted or promoted to current")
}
m.hostMetadataStore = config.NewHostMetadataStore(t.TempDir(), nil)
for _, url := range []string{"https://example.invalid/operator", ""} {
if err := m.hostMetadataStore.Set(entry.HostID, &config.HostMetadata{CustomURL: url}); err != nil {
t.Fatal(err)
}
decorated := m.applyPersistedMetadataToUnifiedResources(view.resources)
for _, r := range decorated {
if r.ID == host.ID && r.CustomURL != url {
t.Fatal("operator URL change/clear was hidden by captured data")
}
}
}
for _, r := range view.resources {
if r.Type != unifiedresources.ResourceTypeVM {
continue
}
baseline := unifiedresources.EvaluateResourceHealth(r, nil, now)
critical := unifiedresources.EvaluateResourceHealth(r, []unifiedresources.ResourceHealthAlert{{ResourceID: r.ID, Level: "critical", Type: "cpu"}}, now)
cleared := unifiedresources.EvaluateResourceHealth(r, nil, now)
if baseline.Verdict != unifiedresources.HealthOK || critical.Verdict != unifiedresources.HealthCritical || !reflect.DeepEqual(cleared, baseline) {
t.Fatal("live alert/clear was hidden by captured data")
}
}
if len(adapter.GetAll()) != 2 {
t.Fatal("continuity overlay wrote back to the live registry")
}
}
var completeProjectionSink models.StateFrontend
var completeProjectionJSONSink []byte
// Exact parent broadcast shape (one coalesce, copy-to-attach, then conversion),
// not the older two-coalesce content oracle above. The unchanged conversion,
// metadata/health decoration and snapshot projection are part of both costs.
func parentFrontendProjectionForTest(m *Monitor, snapshot models.StateSnapshot, resources []unifiedresources.Resource, resolver MetricsTargetResourceStore, now time.Time) models.StateFrontend {
rows := unifiedresources.CoalescePresentationHostResources(resources)
healthAlerts := resourceHealthAlerts(snapshot.ActiveAlerts)
for i := range rows {
m.applyPersistedMetadataToUnifiedResource(&rows[i])
health := unifiedresources.EvaluateResourceHealth(rows[i], healthAlerts, now)
rows[i].Health = &health
}
projected, catalogs := convertPresentationResourcesForBroadcast(attachBroadcastMetricsTargets(rows, resolver))
out := snapshot.ToFrontend()
out.Resources = projected
out.CapabilityCatalog = catalogs.capabilities
out.PolicyCatalog = catalogs.policies
out.AISafeSummaryCatalog = catalogs.aiSafeSummaries
out.ConnectedInfrastructure = buildConnectedInfrastructure(rows, snapshot)
if freshness := latestUnifiedResourceLastSeen(resources); !freshness.IsZero() {
out.LastUpdate = freshness.UnixMilli()
}
return out
}
func BenchmarkBroadcastMetricsSnapshot(b *testing.B) {
graph := projectionFixtureGraph(b)
for _, mode := range []string{"cold", "list-clean", "views-clean", "dirty"} {
for _, encode := range []bool{false, true} {
stage := "projection"
if encode {
stage = "json-frame"
}
for _, bulk := range []bool{false, true} {
path := "point-targets"
if bulk {
path = "bulk-targets"
}
b.Run(fmt.Sprintf("%s/%s/%s", mode, stage, path), func(b *testing.B) {
rr := projectionFixtureRegistry(graph)
adapter := unifiedresources.NewMonitorAdapter(rr)
store := &broadcastProjectionCountingStore{MonitorAdapter: adapter}
m := &Monitor{resourceStore: store}
if mode != "cold" {
rr.List()
}
if mode == "views-clean" {
rr.Workloads()
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if mode == "cold" || mode == "dirty" {
b.StopTimer()
if mode == "cold" {
rr = projectionFixtureRegistry(graph)
adapter = unifiedresources.NewMonitorAdapter(rr)
store.MonitorAdapter = adapter
} else {
changed := rr.List()[0]
changed.Tags = []string{fmt.Sprintf("edit-%d", i)}
rr.IngestResources([]unifiedresources.Resource{changed})
}
b.StartTimer()
}
if bulk {
completeProjectionSink = m.buildBroadcastFrontendStateFromSnapshot(graph.State)
} else {
completeProjectionSink = parentFrontendProjectionForTest(m, graph.State, adapter.GetAll(), adapter, time.Now().UTC())
}
if encode {
var err error
completeProjectionJSONSink, err = json.Marshal(completeProjectionSink)
if err != nil {
b.Fatal(err)
}
}
}
})
}
}
}
}
@@ -607,10 +607,11 @@ func TestBroadcastResourceProjectionCoalescesSplitHostIdentities(t *testing.T) {
source := string(data)
for _, snippet := range []string{
"metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)",
"metricsTargetResolver := unifiedView.metricsTargets",
"metricsTargetResolver = broadcastMetricsTargetResolver(unifiedView.readState)",
"broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)",
"broadcastFrontendResources, broadcastCatalogs := convertPresentationResourcesForBroadcast(",
"attachBroadcastMetricsTargets(broadcastResources, metricsTargetResolver)",
"broadcastFrontendResources, broadcastCatalogs := convertPresentationResourcesForBroadcast(broadcastResources)",
"attachBroadcastMetricsTargetsInPlace(broadcastResources, metricsTargetResolver)",
"frontendState.CapabilityCatalog = broadcastCatalogs.capabilities",
"frontendState.PolicyCatalog = broadcastCatalogs.policies",
"frontendState.AISafeSummaryCatalog = broadcastCatalogs.aiSafeSummaries",
@@ -2957,7 +2958,7 @@ func TestBroadcastProjectionMatchesPreviousPipeline(t *testing.T) {
if !view.freshness.IsZero() {
want.LastUpdate = view.freshness.UnixMilli()
}
got := m.buildBroadcastFrontendStateFromSnapshot(snapshot)
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, func() time.Time { return now })
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
@@ -0,0 +1,125 @@
package monitoring
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
type blockedGuestMetadataFS struct {
config.FileSystem
entered chan struct{}
release chan struct{}
}
func (f *blockedGuestMetadataFS) ReadFile(p string) ([]byte, error) { return os.ReadFile(p) }
func (f *blockedGuestMetadataFS) Stat(p string) (os.FileInfo, error) { return os.Stat(p) }
func (f *blockedGuestMetadataFS) MkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) }
func (f *blockedGuestMetadataFS) WriteFile(p string, b []byte, m os.FileMode) error {
close(f.entered)
<-f.release
return os.WriteFile(p, b, m)
}
func (f *blockedGuestMetadataFS) Rename(a, b string) error { return os.Rename(a, b) }
func TestTenantDeletionRetainsBlockedGuestWriterOwnership(t *testing.T) {
path := t.TempDir()
fs := &blockedGuestMetadataFS{entered: make(chan struct{}), release: make(chan struct{})}
store := config.NewGuestMetadataStore(path, fs)
monitor := &Monitor{guestMetadataStore: store}
mtm := NewMultiTenantMonitor(&config.Config{DataPath: path}, nil, nil)
mtm.monitors["blocked"] = monitor
t.Cleanup(func() {
select {
case <-fs.release:
default:
close(fs.release)
}
_ = store.Close(time.Second)
mtm.Stop()
})
persistGuestIdentity(store, "pve:node:100", "retained", "qemu")
select {
case <-fs.entered:
case <-time.After(time.Second):
t.Fatal("write did not start")
}
if err := mtm.BeginTenantDeletion("blocked"); err == nil {
t.Fatal("deletion accepted incomplete drain")
}
if retained, ok := mtm.PeekMonitor("blocked"); !ok || retained != monitor {
t.Fatal("in-flight writer owner was discarded")
}
mtm.FinishTenantDeletion("blocked")
if _, err := mtm.GetMonitor("blocked"); err == nil {
t.Fatal("deferred cleanup released unsafe lifecycle guard")
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("data path not retained: %v", err)
}
close(fs.release)
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
if err := mtm.BeginTenantDeletion("blocked"); err != nil {
t.Fatal(err)
}
if _, ok := mtm.PeekMonitor("blocked"); ok {
t.Fatal("completed shutdown not removed")
}
if err := store.RememberIdentity("late", "late", "qemu"); err == nil {
t.Fatal("stopped store accepted late identity")
}
if _, err := os.Stat(filepath.Join(path, "guest_metadata.json")); err != nil {
t.Fatal("accepted metadata not persisted")
}
}
func TestTenantDeletionRetainsUnfinishedMonitorLoop(t *testing.T) {
mtm := NewMultiTenantMonitor(&config.Config{}, nil, nil)
_, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
monitor := &Monitor{}
mtm.monitors["loop"] = monitor
mtm.tenantCancel["loop"] = cancel
mtm.tenantDone["loop"] = done
t.Cleanup(func() {
select {
case <-done:
default:
close(done)
}
mtm.Stop()
})
if err := mtm.BeginTenantDeletion("loop"); err == nil {
t.Fatal("unfinished monitoring loop admitted directory deletion")
}
if retained, ok := mtm.PeekMonitor("loop"); !ok || retained != monitor {
t.Fatal("unfinished monitoring loop lost owner")
}
if _, err := mtm.GetMonitor("loop"); err == nil {
t.Fatal("unfinished monitor replaced")
}
close(done)
if err := mtm.BeginTenantDeletion("loop"); err != nil {
t.Fatal(err)
}
}
func TestPersistGuestIdentityEditsAreImmediateAndFieldScoped(t *testing.T) {
store := config.NewGuestMetadataStore(t.TempDir(), nil)
if err := store.Set("pve:node:100", &config.GuestMetadata{CustomURL: "https://operator.example", Tags: []string{"important"}, Notes: []string{"keep"}}); err != nil {
t.Fatal(err)
}
persistGuestIdentity(store, "pve:node:100", "old", "qemu")
persistGuestIdentity(store, "pve:node:100", "new", "qemu")
got := store.Get("pve:node:100")
if got.LastKnownName != "new" || got.CustomURL != "https://operator.example" || got.Tags[0] != "important" || got.Notes[0] != "keep" {
t.Fatalf("producer corrupted metadata: %+v", got)
}
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
}
+80 -26
View File
@@ -4333,6 +4333,13 @@ func buildFrontendStateFromSnapshot(snapshot models.StateSnapshot) models.StateF
}
func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateSnapshot) models.StateFrontend {
return m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, time.Now)
}
// The clock is sampled at the same post-read health-evaluation boundary as
// ordinary broadcasts. Tests can compare complete projections at one instant
// without mistaking a naturally advancing health-age label for lost content.
func (m *Monitor) buildBroadcastFrontendStateFromSnapshotWithClock(snapshot models.StateSnapshot, healthClock func() time.Time) models.StateFrontend {
frontendState := buildFrontendStateFromSnapshot(snapshot)
m.updateResourceStoreForRead(snapshot)
if m != nil && m.alertManager != nil {
@@ -4341,20 +4348,22 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS
}
}
unifiedView := m.currentUnifiedStateView()
metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)
metricsTargetResolver := unifiedView.metricsTargets
if metricsTargetResolver == nil {
metricsTargetResolver = broadcastMetricsTargetResolver(unifiedView.readState)
}
broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)
// Coalescing owns the outer slice. Decorate that one projection in place,
// not three full-resource copies; nested store data is still read-only.
healthAlerts := resourceHealthAlerts(frontendState.ActiveAlerts)
now := time.Now().UTC()
now := healthClock().UTC()
for i := range broadcastResources {
m.applyPersistedMetadataToUnifiedResource(&broadcastResources[i])
health := unifiedresources.EvaluateResourceHealth(broadcastResources[i], healthAlerts, now)
broadcastResources[i].Health = &health
}
broadcastFrontendResources, broadcastCatalogs := convertPresentationResourcesForBroadcast(
attachBroadcastMetricsTargets(broadcastResources, metricsTargetResolver),
)
attachBroadcastMetricsTargetsInPlace(broadcastResources, metricsTargetResolver)
broadcastFrontendResources, broadcastCatalogs := convertPresentationResourcesForBroadcast(broadcastResources)
frontendState.Resources = broadcastFrontendResources
frontendState.CapabilityCatalog = broadcastCatalogs.capabilities
frontendState.PolicyCatalog = broadcastCatalogs.policies
@@ -4871,23 +4880,50 @@ func (m *Monitor) RecentResourceChanges(resourceID string, since time.Time, limi
}
type monitorUnifiedStateView struct {
resources []unifiedresources.Resource
readState unifiedresources.ReadState
freshness time.Time
resources []unifiedresources.Resource
readState unifiedresources.ReadState
freshness time.Time
metricsTargets MetricsTargetResourceStore
}
type unifiedResourceReadStateLister interface {
GetAll() []unifiedresources.Resource
}
type unifiedResourceProjectionLister interface {
GetAllWithMetricsTargets() ([]unifiedresources.Resource, map[string]unifiedresources.MetricsTarget)
}
// This resolver never falls through to the live store, including for an
// unknown ID. A missing target belongs to this captured generation too.
type projectionMetricsTargets map[string]unifiedresources.MetricsTarget
func (targets projectionMetricsTargets) MetricsTargetForResource(id string) *unifiedresources.MetricsTarget {
target, ok := targets[unifiedresources.CanonicalResourceID(id)]
if !ok {
return nil
}
return &target
}
func unifiedProjectionResources(lister unifiedResourceReadStateLister) ([]unifiedresources.Resource, MetricsTargetResourceStore) {
if projection, ok := lister.(unifiedResourceProjectionLister); ok {
resources, targets := projection.GetAllWithMetricsTargets()
return resources, projectionMetricsTargets(targets)
}
return lister.GetAll(), nil
}
func monitorUnifiedStateViewFromSnapshot(snapshot models.StateSnapshot) monitorUnifiedStateView {
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
adapter := unifiedresources.NewMonitorAdapter(registry)
resources, targets := unifiedProjectionResources(adapter)
return monitorUnifiedStateView{
resources: registry.List(),
readState: adapter,
freshness: snapshot.LastUpdate,
resources: resources,
readState: adapter,
freshness: snapshot.LastUpdate,
metricsTargets: targets,
}
}
@@ -4895,10 +4931,12 @@ func monitorUnifiedStateViewFromResources(resources []unifiedresources.Resource,
registry := unifiedresources.NewRegistry(nil)
registry.IngestResources(resources)
adapter := unifiedresources.NewMonitorAdapter(registry)
resources, targets := unifiedProjectionResources(adapter)
return monitorUnifiedStateView{
resources: registry.List(),
readState: adapter,
freshness: freshness,
resources: resources,
readState: adapter,
freshness: freshness,
metricsTargets: targets,
}
}
@@ -4915,8 +4953,9 @@ func (m *Monitor) unifiedStateViewWithStandaloneHostContinuity(view monitorUnifi
return view
}
resources := lister.GetAll()
resources, targets := unifiedProjectionResources(lister)
view.resources = resources
view.metricsTargets = targets
if view.freshness.IsZero() {
view.freshness = latestUnifiedResourceLastSeen(resources)
}
@@ -6479,17 +6518,24 @@ func attachBroadcastMetricsTargets(
}
out := make([]unifiedresources.Resource, len(resources))
for i, resource := range resources {
if resource.MetricsTarget == nil {
resource.MetricsTarget = validBroadcastMetricsTarget(
resolver.MetricsTargetForResource(resource.ID),
)
}
out[i] = resource
}
copy(out, resources)
attachBroadcastMetricsTargetsInPlace(out, resolver)
return out
}
// Only call with the owned outer slice of a coalesced broadcast projection.
// Nested resource data stays read-only, and the resolver returns owned targets.
func attachBroadcastMetricsTargetsInPlace(resources []unifiedresources.Resource, resolver MetricsTargetResourceStore) {
if resolver == nil {
return
}
for i := range resources {
if resources[i].MetricsTarget == nil {
resources[i].MetricsTarget = validBroadcastMetricsTarget(resolver.MetricsTargetForResource(resources[i].ID))
}
}
}
func validBroadcastMetricsTarget(
target *unifiedresources.MetricsTarget,
) *unifiedresources.MetricsTarget {
@@ -7598,6 +7644,12 @@ func monitorLastSeenUnix(value time.Time) int64 {
const guestMetadataDrainTimeout = 2 * time.Second
func (m *Monitor) Stop() {
if err := m.stop(); err != nil {
log.Error().Err(err).Msg("monitor stopped with incomplete guest metadata persistence; data directory must be retained")
}
}
func (m *Monitor) stop() error {
log.Info().Msg("stopping monitor")
// A trailing agent-report refresh must not run against stores closed below.
@@ -7620,9 +7672,7 @@ func (m *Monitor) Stop() {
// Drain background guest-metadata writes before the data directory can be
// torn down. Without this a queued write lands after shutdown and leaves a
// stray guest_metadata.json.tmp behind.
if m.guestMetadataStore != nil {
m.guestMetadataStore.WaitForPendingWrites(guestMetadataDrainTimeout)
}
drainErr := m.guestMetadataStore.Close(guestMetadataDrainTimeout)
// Close persistent metrics store (flushes buffered data)
if m.metricsStore != nil {
@@ -7633,7 +7683,11 @@ func (m *Monitor) Stop() {
}
}
if drainErr != nil {
return drainErr
}
log.Info().Msg("monitor stopped")
return nil
}
// recordAuthFailure records an authentication failure for a node
+2 -27
View File
@@ -872,33 +872,8 @@ func enrichWithPersistedMetadata(metadataStore *config.GuestMetadataStore, byVMI
// persistGuestIdentity updates the metadata store with the last-known name and type for a guest
func persistGuestIdentity(metadataStore *config.GuestMetadataStore, guestKey, name, guestType string) {
existing := metadataStore.Get(guestKey)
if existing == nil {
existing = &config.GuestMetadata{
ID: guestKey,
Tags: []string{},
}
}
guestType = strings.TrimSpace(guestType)
if guestType == "" {
return
}
// Never "downgrade" OCI containers back to LXC. OCI classification can be transiently
// unavailable if Proxmox config reads fail due to permissions or transient API errors.
if existing.LastKnownType == "oci" && guestType != "oci" {
guestType = existing.LastKnownType
}
// Only update if the name or type has changed
if existing.LastKnownName != name || existing.LastKnownType != guestType {
existing.LastKnownName = name
existing.LastKnownType = guestType
// Save without blocking the monitor. The store owns the goroutine so
// Monitor.Stop can drain it; a detached goroutine here could write
// after shutdown, into a data directory that was already being removed.
metadataStore.SetAsync(guestKey, existing)
if err := metadataStore.RememberIdentity(guestKey, name, guestType); err != nil {
log.Error().Err(err).Str("guestKey", guestKey).Msg("failed to admit guest identity update")
}
}
@@ -6718,6 +6718,10 @@ func (s *broadcastProjectionCountingStore) GetAll() []unifiedresources.Resource
s.reads++
return s.MonitorAdapter.GetAll()
}
func (s *broadcastProjectionCountingStore) GetAllWithMetricsTargets() ([]unifiedresources.Resource, map[string]unifiedresources.MetricsTarget) {
s.reads++
return s.MonitorAdapter.GetAllWithMetricsTargets()
}
func (*broadcastProjectionCountingStore) TryReplaceRegistryForRead(models.StateSnapshot, time.Duration, func() map[unifiedresources.DataSource][]unifiedresources.IngestRecord) bool {
return false
}
@@ -6759,6 +6763,10 @@ func TestBroadcastProjectionListsRegistryOnceAndKeepsLiveChanges(t *testing.T) {
if row == nil || row.CPU.Current != 77 || len(row.Tags) != 1 || row.Tags[0] != "new-tag" {
t.Fatalf("mutable row was stale: %#v", row)
}
var health unifiedresources.ResourceHealth
if err := json.Unmarshal(row.Health, &health); err != nil || health.Verdict != unifiedresources.HealthCritical {
t.Fatalf("live critical alert did not reach the freshly projected row: %s (%v)", row.Health, err)
}
if first.Resources[0].CPU.Current != oldCPU {
t.Fatal("later projection mutated an accepted baseline")
}
+9 -12
View File
@@ -20,15 +20,6 @@ func TestPersistGuestIdentity_Concurrent(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
// metadataFile := filepath.Join(tmpDir, "guest_metadata.json") // Actually NewGuestMetadataStore takes directory not file usually? No, it takes root dir?
// Let's verify standard usage. "store := NewGuestMetadataStore(tmpDir, nil)"
// Implementation: func NewGuestMetadataStore(dataPath string, fs FileSystem)
// Inside it does filepath.Join(dataPath, "guest_metadata.json") ?
// Let's re-read NewGuestMetadataStore in internal/config/guest_metadata.go via grep or similar if needed.
// But based on "config.NewGuestMetadataStore(metadataFile)" from my previous code failing, and grep showing "dataPath", it likely takes a Dir or File path.
// grep output: guestMetadataStore := NewGuestMetadataStore(dataPath, c.fs)
// Most likely directory.
store := config.NewGuestMetadataStore(tmpDir, nil)
guestKey := "pve1:node1:100"
@@ -36,8 +27,9 @@ func TestPersistGuestIdentity_Concurrent(t *testing.T) {
// Test basic persistence
persistGuestIdentity(store, guestKey, "VM 100", "qemu")
// Wait a bit since persistGuestIdentity is async
time.Sleep(50 * time.Millisecond)
if !store.WaitForPendingWrites(time.Second) {
t.Fatal("identity writer did not drain")
}
meta := store.Get(guestKey)
if meta == nil || meta.LastKnownName != "VM 100" || meta.LastKnownType != "qemu" {
@@ -55,7 +47,9 @@ func TestPersistGuestIdentity_Concurrent(t *testing.T) {
// Try to update to "lxc"
persistGuestIdentity(store, guestKey, "VM 100", "lxc")
time.Sleep(50 * time.Millisecond)
if !store.WaitForPendingWrites(time.Second) {
t.Fatal("identity writer did not drain")
}
meta = store.Get(guestKey)
if meta.LastKnownType != "oci" {
@@ -65,6 +59,9 @@ func TestPersistGuestIdentity_Concurrent(t *testing.T) {
// Test persistence that shouldn't happen (no change) -> coverage of the if check
// Should not trigger Set()
persistGuestIdentity(store, guestKey, "VM 100", "oci")
if err := store.Close(time.Second); err != nil {
t.Fatal(err)
}
}
func TestEnrichWithPersistedMetadata_Detail(t *testing.T) {
+64 -49
View File
@@ -20,6 +20,7 @@ type MultiTenantMonitor struct {
tenantCancel map[string]context.CancelFunc
tenantDone map[string]chan struct{}
tenantDeleting map[string]struct{}
tenantStopping map[string]struct{}
persistence *config.MultiTenantPersistence
baseConfig *config.Config
wsHub *websocket.Hub
@@ -37,6 +38,7 @@ func NewMultiTenantMonitor(baseCfg *config.Config, persistence *config.MultiTena
tenantCancel: make(map[string]context.CancelFunc),
tenantDone: make(map[string]chan struct{}),
tenantDeleting: make(map[string]struct{}),
tenantStopping: make(map[string]struct{}),
persistence: persistence,
baseConfig: baseCfg, // Used as a template or for global settings
wsHub: wsHub,
@@ -294,47 +296,34 @@ func (mtm *MultiTenantMonitor) Stop() {
log.Info().Msg("stopping MultiTenantMonitor and all tenant instances")
mtm.mu.Lock()
mtm.globalCancel()
monitors := make([]*Monitor, 0, len(mtm.monitors))
cancels := make([]context.CancelFunc, 0, len(mtm.tenantCancel))
doneSignals := make([]chan struct{}, 0, len(mtm.tenantDone))
for orgID, monitor := range mtm.monitors {
if cancel := mtm.tenantCancel[orgID]; cancel != nil {
cancels = append(cancels, cancel)
}
if done := mtm.tenantDone[orgID]; done != nil {
doneSignals = append(doneSignals, done)
}
monitors = append(monitors, monitor)
if mtm.globalCancel != nil {
mtm.globalCancel()
}
orgIDs := make([]string, 0, len(mtm.monitors))
for orgID := range mtm.monitors {
orgIDs = append(orgIDs, orgID)
}
mtm.tenantCancel = make(map[string]context.CancelFunc)
mtm.tenantDone = make(map[string]chan struct{})
mtm.monitors = make(map[string]*Monitor)
mtm.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
for _, done := range doneSignals {
waitForTenantMonitorShutdown("", done)
}
for _, monitor := range monitors {
monitor.Stop()
for _, orgID := range orgIDs {
if err := mtm.removeTenant(orgID, false); err != nil {
log.Error().Err(err).Str("org_id", orgID).Msg("tenant shutdown incomplete; retaining runtime ownership and data")
}
}
}
// RemoveTenant stops and removes a specific tenant's monitor.
// Useful for offboarding or manual reloading.
func (mtm *MultiTenantMonitor) RemoveTenant(orgID string) {
mtm.removeTenant(orgID, false)
if err := mtm.removeTenant(orgID, false); err != nil {
log.Error().Err(err).Str("org_id", orgID).Msg("tenant removal incomplete; retaining runtime ownership and data")
}
}
// BeginTenantDeletion prevents lazy monitor initialization while a tenant's
// persistence is being removed. Call FinishTenantDeletion after the
// persistence operation completes, whether it succeeds or fails.
func (mtm *MultiTenantMonitor) BeginTenantDeletion(orgID string) {
mtm.removeTenant(orgID, true)
// persistence operation completes. An incomplete shutdown retains its guard.
func (mtm *MultiTenantMonitor) BeginTenantDeletion(orgID string) error {
return mtm.removeTenant(orgID, true)
}
// FinishTenantDeletion releases the temporary lifecycle guard installed by
@@ -347,46 +336,70 @@ func (mtm *MultiTenantMonitor) FinishTenantDeletion(orgID string) {
}
mtm.mu.Lock()
delete(mtm.tenantDeleting, orgID)
// An incomplete removal still owns a runtime or writer. Do not release
// its guard merely because an offboarding caller ran deferred cleanup.
if _, retained := mtm.monitors[orgID]; !retained {
delete(mtm.tenantDeleting, orgID)
}
mtm.mu.Unlock()
}
func (mtm *MultiTenantMonitor) removeTenant(orgID string, deleting bool) {
func (mtm *MultiTenantMonitor) removeTenant(orgID string, deleting bool) error {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return
return nil
}
mtm.mu.Lock()
if deleting {
if mtm.tenantDeleting == nil {
mtm.tenantDeleting = make(map[string]struct{})
}
mtm.tenantDeleting[orgID] = struct{}{}
if mtm.tenantDeleting == nil {
mtm.tenantDeleting = make(map[string]struct{})
}
monitor, exists := mtm.monitors[orgID]
if mtm.tenantStopping == nil {
mtm.tenantStopping = make(map[string]struct{})
}
if _, stopping := mtm.tenantStopping[orgID]; stopping {
mtm.mu.Unlock()
return fmt.Errorf("organization %q shutdown is already in progress", orgID)
}
mtm.tenantDeleting[orgID] = struct{}{}
mtm.tenantStopping[orgID] = struct{}{}
monitor := mtm.monitors[orgID]
cancel := mtm.tenantCancel[orgID]
done := mtm.tenantDone[orgID]
delete(mtm.monitors, orgID)
delete(mtm.tenantCancel, orgID)
delete(mtm.tenantDone, orgID)
mtm.mu.Unlock()
if !exists {
return
}
defer func() {
mtm.mu.Lock()
delete(mtm.tenantStopping, orgID)
mtm.mu.Unlock()
}()
log.Info().Str("org_id", orgID).Msg("stopping and removing tenant monitor")
if cancel != nil {
cancel()
}
waitForTenantMonitorShutdown(orgID, done)
monitor.Stop()
if !waitForTenantMonitorShutdown(orgID, done) {
return fmt.Errorf("organization %q monitoring loop has not stopped", orgID)
}
if monitor != nil {
if err := monitor.stop(); err != nil {
return fmt.Errorf("organization %q guest metadata shutdown incomplete: %w", orgID, err)
}
}
mtm.mu.Lock()
delete(mtm.monitors, orgID)
delete(mtm.tenantCancel, orgID)
delete(mtm.tenantDone, orgID)
if !deleting {
delete(mtm.tenantDeleting, orgID)
}
mtm.mu.Unlock()
return nil
}
func waitForTenantMonitorShutdown(orgID string, done <-chan struct{}) {
func waitForTenantMonitorShutdown(orgID string, done <-chan struct{}) bool {
if done == nil {
return
return true
}
timer := time.NewTimer(tenantMonitorShutdownTimeout)
@@ -394,12 +407,14 @@ func waitForTenantMonitorShutdown(orgID string, done <-chan struct{}) {
select {
case <-done:
return true
case <-timer.C:
logger := log.Warn().Dur("timeout", tenantMonitorShutdownTimeout)
if orgID != "" {
logger = logger.Str("org_id", orgID)
}
logger.Msg("timed out waiting for tenant monitor loop to exit")
return false
}
}
@@ -496,6 +496,18 @@ func (a *MonitorAdapter) GetAll() []Resource {
return registry.List()
}
// GetAllWithMetricsTargets materializes one complete broadcast read, binding
// its detached target map to the same registry as its detached resource list.
// The adapter may publish a replacement immediately after this capture; neither
// half of the returned read then consults that newer generation.
func (a *MonitorAdapter) GetAllWithMetricsTargets() ([]Resource, map[string]MetricsTarget) {
registry := a.currentRegistry()
if registry == nil {
return nil, nil
}
return registry.ListWithMetricsTargets()
}
func (a *MonitorAdapter) unifiedAIAdapter() *UnifiedAIAdapter {
registry := a.currentRegistry()
if registry == nil {
@@ -971,3 +971,40 @@ func TestHostContinuityOverlayReusedWithinRegistryGeneration(t *testing.T) {
t.Fatal("overlay was reused past its maximum age")
}
}
func TestMonitorAdapterProjectionSnapshotKeepsReplacedGeneration(t *testing.T) {
var absent *MonitorAdapter
rows, targets := absent.GetAllWithMetricsTargets()
if rows != nil || targets != nil {
t.Fatal("nil adapter invented a projection")
}
adapter := NewMonitorAdapter(NewRegistry(nil))
snapshot := readRefreshSnapshot("before")
adapter.PopulateFromSnapshot(snapshot)
first, firstTargets := adapter.GetAllWithMetricsTargets()
snapshot.VMs[0].Name = "after"
snapshot.VMs[0].ID = "new-node:201"
snapshot.VMs[0].VMID = 201
// Observation time is deliberately unchanged.
adapter.PopulateFromSnapshot(snapshot)
second, secondTargets := adapter.GetAllWithMetricsTargets()
if len(first) != 1 || len(second) != 1 || first[0].Name != "before" || second[0].Name != "after" {
t.Fatal("replacement lost complete fresh resource content")
}
if firstTargets[first[0].ID].ResourceID == secondTargets[second[0].ID].ResourceID {
t.Fatal("replacement retained old history coordinates")
}
for _, r := range second {
if want := adapter.MetricsTargetForResource(r.ID); want == nil || *want != secondTargets[r.ID] {
t.Fatal("target differs from the generation's point resolver")
}
}
other := NewMonitorAdapter(NewRegistry(nil))
snapshot.VMs[0].ID = "other-tenant:501"
snapshot.VMs[0].Name = "other-tenant"
other.PopulateFromSnapshot(snapshot)
otherRows, otherTargets := other.GetAllWithMetricsTargets()
if reflect.DeepEqual(otherRows, second) || reflect.DeepEqual(otherTargets, secondTargets) {
t.Fatal("separate adapters shared a tenant projection")
}
}
+30 -1
View File
@@ -1321,6 +1321,21 @@ func seededVMwareSourceID(resource *Resource) string {
// the read lock and copy it without repeating identity/scope/policy derivation.
// The typed views remain lazy, so a List-only consumer pays for no extra copy.
func (rr *ResourceRegistry) List() []Resource {
resources, _ := rr.listMaterialized(false)
return resources
}
// ListWithMetricsTargets captures detached resources and their history targets
// from the same registry generation. Resolve all source mappings in one pass,
// without forcing the unrelated typed views to be built. In particular a
// freshly ingested registry must not scan every mapping once per broadcast row.
// Targets are separate: host coalescing must select the presentation identity
// before a missing target is attached, just as with individual target lookups.
func (rr *ResourceRegistry) ListWithMetricsTargets() ([]Resource, map[string]MetricsTarget) {
return rr.listMaterialized(true)
}
func (rr *ResourceRegistry) listMaterialized(withTargets bool) ([]Resource, map[string]MetricsTarget) {
for {
rr.mu.RLock()
if rr.canonicalMetadataDirty {
@@ -1332,13 +1347,27 @@ func (rr *ResourceRegistry) List() []Resource {
// rather than copy a generation that is no longer materialized.
continue
}
var sourceTargets map[string][]SourceTarget
var targets map[string]MetricsTarget
if withTargets {
sourceTargets = rr.cachedSourceTargets
if rr.viewsDirty || sourceTargets == nil {
sourceTargets = rr.buildSourceTargetsIndexLocked()
}
targets = make(map[string]MetricsTarget, len(rr.resources))
}
out := make([]Resource, 0, len(rr.resources))
for _, r := range rr.resources {
out = append(out, cloneMaterializedResource(r))
if withTargets {
if target := rr.metricsTargetFromSourceTargets(r, sourceTargets[r.ID]); target != nil {
targets[r.ID] = *target
}
}
}
rr.mu.RUnlock()
sortResourcesByName(out)
return out
return out, targets
}
}
@@ -0,0 +1,99 @@
package unifiedresources
import (
"fmt"
"sync"
"testing"
)
// Exercise the capture while a writer changes the mapping and row together.
// Separate List/point reads can legitimately observe different generations;
// this paired bulk read cannot. The test never touches persistence or drivers.
func TestRegistryProjectionSnapshotConcurrentMappingEdits(t *testing.T) {
rr := NewRegistry(nil)
rr.IngestSnapshot(benchmarkVMState(1))
id := rr.List()[0].ID
set := func(version int) {
rr.mu.Lock()
defer rr.mu.Unlock()
name := fmt.Sprintf("coordinate-%d", version)
rr.resources[id].Name = name
rr.bySource[SourceProxmox] = map[string]string{name: id}
rr.invalidateViewsLocked()
}
set(0)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
set(i % 2)
}
}()
defer wg.Wait()
for i := 0; i < 200; i++ {
rows, targets := rr.ListWithMetricsTargets()
if len(rows) != 1 || rows[0].Name != targets[id].ResourceID {
t.Fatalf("mixed row/target generation: row=%q target=%q", rows[0].Name, targets[id].ResourceID)
}
}
}
var projectionResourceSink []Resource
var projectionTargetSink map[string]MetricsTarget
// Include the entire owned List and every history coordinate. These are not
// point-lookup timings, nor a replacement for the old clone-allocation envelope.
func BenchmarkRegistryProjectionSnapshot(b *testing.B) {
for _, count := range []int{0, 1, 1000} {
for _, mode := range []string{"cold", "list-clean", "views-clean", "dirty"} {
for _, bulk := range []bool{false, true} {
name := "point-targets"
if bulk {
name = "bulk-targets"
}
b.Run(fmt.Sprintf("%d/%s/%s", count, mode, name), func(b *testing.B) {
state := benchmarkVMState(count)
rr := NewRegistry(nil)
rr.IngestSnapshot(state)
if mode != "cold" {
rr.List()
}
if mode == "views-clean" {
rr.VMs()
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if mode == "cold" {
b.StopTimer()
rr = NewRegistry(nil)
rr.IngestSnapshot(state)
b.StartTimer()
}
if mode == "dirty" {
rr.mu.Lock()
for _, r := range rr.resources {
r.Tags = []string{fmt.Sprintf("version-%d", i)}
break
}
rr.invalidateViewsLocked()
rr.mu.Unlock()
}
if bulk {
projectionResourceSink, projectionTargetSink = rr.ListWithMetricsTargets()
} else {
projectionResourceSink = rr.List()
projectionTargetSink = make(map[string]MetricsTarget, len(projectionResourceSink))
for _, row := range projectionResourceSink {
if target := rr.MetricsTarget(row.ID); target != nil {
projectionTargetSink[row.ID] = *target
}
}
}
}
})
}
}
}
}
@@ -6968,3 +6968,68 @@ func TestEvaluateResourceHealthKeepsStaleTelemetryBehindAWinningAlert(t *testing
t.Fatalf("expected only the alert reason for a fresh source, got %+v", health.Reasons)
}
}
// A List-only broadcast must capture history coordinates without constructing
// every typed view, and without trusting freshness timestamps as mutation keys.
func TestRegistryProjectionSnapshotDetachesAndTracksMappings(t *testing.T) {
rr := NewRegistry(nil)
rr.IngestSnapshot(benchmarkVMState(3))
resources, targets := rr.ListWithMetricsTargets()
if !reflect.DeepEqual(resources, rr.List()) {
t.Fatal("bulk read changed resource content")
}
if !rr.viewsDirty || rr.cachedVMs != nil {
t.Fatal("projection forced unrelated typed views to materialize")
}
if len(targets) != len(resources) {
t.Fatalf("targets=%d resources=%d", len(targets), len(resources))
}
for _, r := range resources {
if want := rr.MetricsTarget(r.ID); want == nil || *want != targets[r.ID] {
t.Fatalf("target for %s differs from point resolver", r.ID)
}
}
id := resources[0].ID
observedAt := resources[0].LastSeen
rr.mu.Lock()
for key, mapped := range rr.bySource[SourceProxmox] {
if mapped == id {
delete(rr.bySource[SourceProxmox], key)
}
}
rr.bySource[SourceProxmox]["new-history-coordinate"] = id
rr.resources[id].Tags = []string{"customer-data"}
rr.invalidateViewsLocked()
rr.mu.Unlock()
changed, updatedTargets := rr.ListWithMetricsTargets()
if updatedTargets[id].ResourceID != "new-history-coordinate" {
t.Fatal("same-time mapping change left an old target")
}
if targets[id].ResourceID == "new-history-coordinate" {
t.Fatal("later mapping edit mutated a prior target snapshot")
}
for _, r := range changed {
if r.ID == id {
if !r.LastSeen.Equal(observedAt) || r.Policy.Sensitivity != ResourceSensitivityRestricted {
t.Fatal("same-time metadata edit was stale")
}
r.Canonical.Aliases[0] = "caller-change"
r.Tags[0] = "caller-change"
r.Policy.Routing.Redact = append(r.Policy.Routing.Redact, ResourceRedactionPath)
}
}
again, againTargets := rr.ListWithMetricsTargets()
if !reflect.DeepEqual(againTargets, updatedTargets) {
t.Fatal("detached map mutation escaped")
}
for _, r := range again {
if r.ID == id && (r.Tags[0] != "customer-data" || r.Canonical.Aliases[0] == "caller-change") {
t.Fatal("caller changed live registry")
}
}
empty := NewRegistry(nil)
rows, emptyTargets := empty.ListWithMetricsTargets()
if len(rows) != 0 || len(emptyTargets) != 0 {
t.Fatal("empty projection invented resources or targets")
}
}
@@ -0,0 +1,52 @@
package installtests
import (
"math"
"os"
"regexp"
"strconv"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// Check the guide's actual worked examples against the normaliser used by
// container ingestion, History and alert evaluation, not a second formula.
func TestContainerCPUGuidanceExamplesMatchNormalisation(t *testing.T) {
guide, err := os.ReadFile(repoFile("docs", "DOCKER.md"))
if err != nil {
t.Fatal(err)
}
rows := regexp.MustCompile(`(?m)^\| (\d+) \| ([\d.]+)% \| (?:About )?([\d.]+)% \|$`).FindAllStringSubmatch(string(guide), -1)
if len(rows) != 3 {
t.Fatalf("expected three worked host-capacity CPU examples, got %d", len(rows))
}
for _, row := range rows {
cpus, err := strconv.Atoi(row[1])
if err != nil {
t.Fatal(err)
}
raw, err := strconv.ParseFloat(row[2], 64)
if err != nil {
t.Fatal(err)
}
printed, err := strconv.ParseFloat(row[3], 64)
if err != nil {
t.Fatal(err)
}
actual := models.NormalizeDockerContainerCPUCapacityPercent(raw, cpus)
if math.Abs(actual-printed) > 0.005 {
t.Errorf("%s disagrees with the production normaliser: %g", row[0], actual)
}
}
for _, name := range []string{"DOCKER.md", "TROUBLESHOOTING.md"} {
doc, err := os.ReadFile(repoFile("docs", name))
if err != nil {
t.Fatal(err)
}
shipped, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", name))
if err != nil || string(doc) != string(shipped) {
t.Fatalf("%s shipped help does not match the checked canonical guide", name)
}
}
}
@@ -0,0 +1,164 @@
//go:build !windows
package installtests
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
type nativeGuidanceStep struct {
Name, If, Shell, Run string
}
func nativeGuidanceToolSteps(content []byte) (nativeGuidanceStep, nativeGuidanceStep, error) {
var workflow struct {
Jobs map[string]struct{ Steps []nativeGuidanceStep }
}
if err := yaml.Unmarshal(content, &workflow); err != nil {
return nativeGuidanceStep{}, nativeGuidanceStep{}, err
}
steps := workflow.Jobs["native-agent"].Steps
bootstrapIndex, verifyIndex, installerIndex := -1, -1, -1
for i, step := range steps {
switch step.Name {
case "Supply GNU timeout for macOS guidance checks":
bootstrapIndex = i
case "Verify native Unix timeout prerequisite":
verifyIndex = i
case "Test native Unix installer contracts":
installerIndex = i
}
}
if bootstrapIndex < 0 || verifyIndex <= bootstrapIndex || installerIndex <= verifyIndex {
return nativeGuidanceStep{}, nativeGuidanceStep{}, fmt.Errorf("native guidance needs GNU tool supply and verification before installer tests")
}
bootstrap, verify := steps[bootstrapIndex], steps[verifyIndex]
if bootstrap.If != "runner.os == 'macOS'" || verify.If != "matrix.unix" || steps[installerIndex].If != "matrix.unix" || bootstrap.Shell != "bash" || verify.Shell != "bash" {
return bootstrap, verify, fmt.Errorf("GNU tool preparation must cover macOS, verification and installer tests must cover every native Unix job")
}
return bootstrap, verify, nil
}
func TestNativeGuidanceToolsSupplyAndVerifyRealGNUTimeout(t *testing.T) {
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
if err != nil {
t.Fatal(err)
}
bootstrap, verify, err := nativeGuidanceToolSteps(content)
if err != nil {
t.Fatal(err)
}
realTimeout, err := exec.LookPath("timeout")
if err != nil {
t.Fatal("native Unix verification requires the real GNU timeout prerequisite")
}
for _, scenario := range []string{"real GNU tool", "install failure", "missing tool", "non-GNU tool"} {
t.Run(scenario, func(t *testing.T) {
root := t.TempDir()
bin, prefix := filepath.Join(root, "bin"), filepath.Join(root, "coreutils with spaces")
coreutilsBin, runnerTemp := filepath.Join(prefix, "bin"), filepath.Join(root, "runner temp")
for _, dir := range []string{bin, coreutilsBin, runnerTemp} {
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
}
// Only Homebrew's installer is simulated. The positive timeout is
// the real executable; the copied TERM/KILL recipes remain unchanged.
const brew = `#!/bin/sh
printf '%s\n' "$*" >> "$BREW_CALLS"
case "$*" in
'install coreutils') exit "${BREW_EXIT:-0}" ;;
'--prefix coreutils') printf '%s\n' "$COREUTILS_PREFIX" ;;
*) exit 64 ;;
esac
`
if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(brew), 0700); err != nil {
t.Fatal(err)
}
tool := filepath.Join(coreutilsBin, "gtimeout")
switch scenario {
case "real GNU tool", "install failure":
if err := os.Symlink(realTimeout, tool); err != nil {
t.Fatal(err)
}
case "non-GNU tool":
if err := os.WriteFile(tool, []byte("#!/bin/sh\nprintf 'BSD timeout\\n'\n"), 0700); err != nil {
t.Fatal(err)
}
}
pathFile, callsFile := filepath.Join(root, "github-path"), filepath.Join(root, "brew-calls")
installExit := "0"
if scenario == "install failure" {
installExit = "23"
}
env := append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "GITHUB_PATH="+pathFile,
"COREUTILS_PREFIX="+prefix, "BREW_CALLS="+callsFile, "BREW_EXIT="+installExit, "RUNNER_TEMP="+runnerTemp)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", bootstrap.Run)
cmd.Env = env
output, runErr := cmd.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("prerequisite observation did not finish: %v", ctx.Err())
}
if scenario != "real GNU tool" {
wantExit := 1
if scenario == "install failure" {
wantExit = 23
}
exit, ok := runErr.(*exec.ExitError)
if !ok || exit.ExitCode() != wantExit {
t.Fatalf("prerequisite exit %v, want %d: %s", runErr, wantExit, output)
}
if _, err := os.Stat(pathFile); !os.IsNotExist(err) {
t.Fatal("failed prerequisite exported an executable search path")
}
return
}
if runErr != nil {
t.Fatalf("GNU prerequisite failed: %v: %s", runErr, output)
}
path, err := os.ReadFile(pathFile)
if err != nil {
t.Fatal(err)
}
timeoutBin := strings.TrimSuffix(string(path), "\n")
if filepath.Dir(timeoutBin) != runnerTemp || !strings.HasPrefix(filepath.Base(timeoutBin), "pulse-guidance-tools.") {
t.Fatal("bootstrap did not export its newly owned timeout directory")
}
entries, err := os.ReadDir(timeoutBin)
if err != nil || len(entries) != 1 || entries[0].Name() != "timeout" {
t.Fatal("bootstrap must not replace native macOS utilities other than timeout")
}
if target, err := os.Readlink(filepath.Join(timeoutBin, "timeout")); err != nil || target != tool {
t.Fatal("timeout must resolve to the exact installed GNU executable")
}
calls, err := os.ReadFile(callsFile)
if err != nil || string(calls) != "install coreutils\n--prefix coreutils\n" {
t.Fatal("bootstrap changed its fixed Homebrew dependency")
}
cmd = exec.CommandContext(ctx, "bash", "-c", verify.Run)
cmd.Env = append(env, "PATH="+timeoutBin+":"+os.Getenv("PATH"))
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("next workflow step cannot use supplied GNU timeout: %v: %s", err, output)
}
cmd = exec.CommandContext(ctx, "bash", "-c", verify.Run)
cmd.Env = append(env, "PATH="+bin+":"+os.Getenv("PATH"))
if err := os.WriteFile(filepath.Join(bin, "timeout"), []byte("#!/bin/sh\nprintf 'BSD timeout\\n'\n"), 0700); err != nil {
t.Fatal(err)
}
if output, err := cmd.CombinedOutput(); err == nil {
t.Fatalf("Unix prerequisite probe accepted a non-GNU timeout: %s", output)
}
})
}
}