Merge origin/main into fix/operator-lock-survives-identity-change

This commit is contained in:
rcourtman
2026-10-10 09:28:22 +01:00
80 changed files with 5270 additions and 337 deletions
+43 -18
View File
@@ -254,6 +254,29 @@ jobs:
PULSE_MULTI_TENANT_ENABLED: "true"
run: node scripts/pretest.mjs
# One shared fixture fault previously consumed 47 minutes of repeated
# setup/login failures before probation hit the job limit. Admit the
# same authenticated inventory and history once, before either tier.
- name: Admit the default mock runtime
id: fixture
timeout-minutes: 8
working-directory: tests/integration
env:
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true"
PULSE_MULTI_TENANT_ENABLED: "true"
run: npx playwright test --config=fixture-readiness.config.ts
- name: Upload failed fixture admission
if: ${{ !cancelled() && steps.fixture.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fixture-readiness-shard-${{ matrix.shard }}
path: |
tests/integration/playwright-report-readiness/
tests/integration/test-results-readiness/
retention-days: 7
# Two-tier run (see PROBATION_SPECS in tests/integration/e2e-tiering.mjs
# for the tier mechanism and the promotion/demotion rule). The stable tier
# is the gate; the probation tier reuses the same containers but reports
@@ -281,6 +304,26 @@ jobs:
working-directory: tests/integration
run: node scripts/report-stable-e2e-failures.mjs test-results/junit.xml
# Preserve the completed stable failure before observational work can
# reach the job deadline. Probation must not take its receipts with it.
- name: Upload Playwright report
if: ${{ !cancelled() && steps.stable.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-shard-${{ matrix.shard }}
path: tests/integration/playwright-report/
retention-days: 3
# The HTML report already contains screenshots, videos and traces.
# Keep only the separate machine-readable result here.
- name: Upload JUnit results
if: ${{ !cancelled() && steps.stable.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-results-shard-${{ matrix.shard }}
path: tests/integration/test-results/junit.xml
retention-days: 3
# Runs even when the stable tier failed (its data still counts toward
# promotion), but not when the stable tier was skipped — that means the
# environment never came up and every probation spec would fail on it.
@@ -332,24 +375,6 @@ jobs:
echo "=== Mock GitHub server logs ==="
docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container"
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-shard-${{ matrix.shard }}
path: tests/integration/playwright-report/
retention-days: 3
# The HTML report already contains screenshots, videos and traces.
# Keep only the separate machine-readable result here.
- name: Upload JUnit results
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-results-shard-${{ matrix.shard }}
path: tests/integration/test-results/junit.xml
retention-days: 3
# continue-on-error keeps the job green on a probation failure, so
# failure() never fires for these; key off the step outcome instead.
- name: Upload probation Playwright report
+99 -16
View File
@@ -634,39 +634,122 @@ Version fields are returned as plain semantic versions (no leading `v`).
Returns a small public config payload (update channel, auto-update enabled).
### List Nodes
`GET /api/config/nodes`
`GET /api/config/nodes` (admin, `settings:read`)
This administrative inventory covers saved **PVE, PBS and PMG connections**,
not individual VMs or agents. Keep its response private. Use the returned
connection `id` for updates, deletion and tests; a display name, VMID or another
installation's node name is not that ID. Re-read the inventory before a change
rather than guessing an ID from a list position.
### Add Node
`POST /api/config/nodes`
`POST /api/config/nodes` (admin, `settings:write`)
Adding a connection saves credentials and enables collection; it is not a
passive validation step. Use an existing dedicated, least-privilege platform
token. The **Pulse API token** in the private header file authorises this
configuration request; the **Proxmox token** in the JSON body authorises reads
from the monitored platform. They are different credentials. Do not use a root
password or widen a department-scoped token to make denied peer reads succeed;
follow the [scoped-access checks](TROUBLESHOOTING.md#check-permissions-proxmox).
For PBS and PMG, use their [PBS](PBS.md#method-1-api-only-connection-recommended)
and [PMG](MAIL_GATEWAY.md) token guidance. PBS password-based onboarding can
also try to create a token and ACLs on the PBS server; it is not read-only.
Prepare the request in a private editor, not a credential-bearing shell
assignment, here-document or command argument. Use the same Bash session as
the [private-response helper](#api-token-recommended):
```bash
node_request_dir=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/pulse-node.XXXXXX") &&
node_request_file="$node_request_dir/node.json" &&
(umask 077; set -C; : > "$node_request_file") &&
vi "$node_request_file"
```
If preparation or the editor fails, stop. In that editor, enter the following
JSON, replacing the example hostname, connection name, full platform token ID
(`user@realm!token`) and token-secret placeholder with your intended values:
```json
{
"type": "pve",
"name": "Proxmox 1",
"host": "https://198.51.100.10:8006",
"user": "root@pam",
"password": "password"
"name": "PVE connection A",
"host": "https://pve.example.net:8006",
"tokenName": "pulse-monitor@pve!pulse-readonly",
"tokenValue": "<platform-token-secret>",
"verifySSL": true
}
```
### Test Connection
`POST /api/config/nodes/test-connection`
Validate credentials before saving.
Set `verifySSL` explicitly to `true`: omission on these new-connection and
test request paths does **not** enable certificate verification. The example
requires a certificate trusted by the Pulse server for that hostname. For a
self-signed/private certificate, also supply `fingerprint` with the SHA-256
certificate fingerprint verified through an independent trusted channel.
A fingerprint merely fetched from the endpoint is not established trust.
Do not clear an existing pin, set `verifySSL` to `false`, or change to HTTP to
work around a certificate failure.
### Test Node Config (Validation Only)
`POST /api/config/nodes/test-config`
Validates node config without saving.
After reviewing this one intended configuration change, send the file:
```bash
pulse_api POST /api/config/nodes < "$node_request_file"
```
The helper retains the response privately and makes one bounded request,
without retries. A lost response can follow a successful save: re-read the
saved inventory before deciding whether another request is needed. HTTP success
does not establish fresh readings for every node, guest, backup or History view.
Keep both request and response files out of reports and shared repositories;
remove the temporary request under your credential policy when no longer needed.
### Test Connection
`POST /api/config/nodes/test-connection` (admin, `settings:write`)
Tests supplied credentials with live platform requests without saving the
connection. Use the same private JSON-file transport and explicit TLS settings,
only when a live connection test is appropriate; it is not a required precursor
to filing a report. Success covers the reads performed by that test, not all
collection permissions, guest recovery or sustained monitoring.
### Test Node Config (No Save, Live Request)
`POST /api/config/nodes/test-config` (admin, `settings:write`)
This also contacts the platform using the supplied credentials; **no save**
does not mean offline schema validation. Do not run either test merely to
collect new evidence during a freeze/thaw, backup or unresponsive-host incident.
Use existing observations and the [safe diagnostic guidance](TROUBLESHOOTING.md#collect-diagnostics-safely).
### Update Node
`PUT /api/config/nodes/{id}`
`PUT /api/config/nodes/{id}` (admin, `settings:write`)
Review the current connection and submit only the intended supported changes
from a private JSON file. Do not copy masked credentials from a GET response
back as new secrets. Credential rotation and trust changes are separate
decisions; preserve the existing credential and fingerprint when not changing
them. A response failure does not prove the update was unapplied.
### Delete Node
`DELETE /api/config/nodes/{id}`
`DELETE /api/config/nodes/{id}` (admin, `settings:write`)
Removes the Pulse connection, not the platform workloads or the upstream token.
Collection and related alert coverage can stop. Check the exact connection and
arrange any needed independent coverage before an intentional removal; do not
delete and re-add a connection to diagnose missing readings or identity mixing.
### Test Node (Legacy)
`POST /api/config/nodes/{id}/test`
`POST /api/config/nodes/{id}/test` (admin, `settings:write`)
Uses the saved connection for a live test. It is not a passive read of its
last result or a guarantee that ordinary collection recovered.
### Refresh Cluster Nodes
`POST /api/config/nodes/{id}/refresh-cluster`
`POST /api/config/nodes/{id}/refresh-cluster` (admin, `settings:write`)
Actively rediscovers a saved PVE connection's cluster membership. It does not
select authorised nodes, grant missing permissions or repair an unrelated
standalone installation. Keep intentional token restrictions intact.
### Export Configuration
`POST /api/config/export` (instance admin for the default organization, tenant
+94 -15
View File
@@ -936,32 +936,111 @@ an agent assignment remain available in every edition.
### ICMP probe privileges
ICMP probes run the system `ping` binary, which needs the `CAP_NET_RAW`
capability. The systemd unit written by the installer hardens the service
with `NoNewPrivileges=true`, which strips ping's setuid bit and file
capabilities, so the unit also grants the capability directly with
`AmbientCapabilities=CAP_NET_RAW`. Units written by older versions of the
installer lack that line, and ICMP probes fail with
`icmp probe failed: ping: socktype: SOCK_RAW ... missing cap_net_raw+p capability`.
ICMP checks execute the system `ping` binary **on the selected observation
host**. A local permission error (`Operation not permitted`, `cap_net_raw` or
`icmp probe blocked`) is not evidence that the target is down, nor proof that
one particular service setting caused it. First distinguish **This Pulse
server** from an external agent observation. Changing the server's unit cannot
repair an agent's permissions on another host.
To fix an existing install, either re-run the install script (it rewrites
the unit) or add the capability as an override:
For Linux systemd servers, the current installer grants both
`AmbientCapabilities=CAP_NET_RAW` and `CapabilityBoundingSet=CAP_NET_RAW`, while
keeping `NoNewPrivileges=true`. This allows the child `ping` to inherit the
capability without gaining it through setuid or file capabilities. Older units
can lack the ambient grant. **Updates preserve an existing service unit**;
rerunning an installer is not a reliable unit repair, even if an older error
message recommends it. Do not reinstall over a working data directory.
#### Inspect the actual systemd server first
On the machine running the Pulse server, read only these effective properties.
For a Pulse LXC, run this **inside the Pulse LXC**, not on the Proxmox host or a
monitored guest. The example uses `pulse.service`; substitute your actual server
unit throughout, such as `pulse-backend.service` on some older installs,
**not pulse-agent.service**:
```bash
systemctl edit pulse # pulse-backend on ProxmoxVE community-script installs
timeout --signal=TERM --kill-after=1s 8s systemctl show pulse.service \
--property=LoadState,ActiveState,User,NoNewPrivileges,AmbientCapabilities,CapabilityBoundingSet
```
Interpret the result only after a successful read with `LoadState=loaded`.
A missing unit, failed or timed-out read is **unknown**, not an empty capability
set or a stopped server. This command does not print environment values,
credentials or full unit contents; keep those private.
| Existing observation | What follows |
| --- | --- |
| `NoNewPrivileges=yes`, ceiling includes `cap_net_raw`, ambient grant missing | An older non-root server unit may need the narrow override below. |
| Both capability properties already include `cap_net_raw` | Do not add the grant again. Check the selected observation host, installed ping and its OS/container restrictions through normal administration. |
| Ceiling excludes `cap_net_raw`, or the deployment deliberately forbids it | An ambient line alone cannot overcome the ceiling. Preserve that restriction; do not replace the capability set with a permissive one. |
| `User=` is empty/root, or the service is custom | This is not the standard non-root server recipe. Review its existing privilege and data-access requirements using [manual-service guidance](INSTALL.md#2-bare-metal--systemd). Do not change the user or grant broader privileges just to clear the error. |
#### Narrow override for an older non-root server
Use this only for an **active** non-root server whose capability ceiling already
permits `cap_net_raw`, whose ambient grant is missing, and where granting it is
consistent with the deployment's intended policy. Keep deliberately inactive
services inactive. Record the existing drop-in locally before editing; it may
contain private configuration. Preserve every unrelated setting:
```bash
sudo systemctl edit pulse.service
```
Add just this service setting to the drop-in:
```ini
[Service]
AmbientCapabilities=CAP_NET_RAW
```
Then `systemctl daemon-reload && systemctl restart pulse`.
Keep `NoNewPrivileges` enabled; do not run Pulse as root, set file capabilities
on the system-wide ping binary or remove sandbox settings. Do not reset or widen
`CapabilityBoundingSet` to make this example apply to a different profile.
Docker installs are unaffected: Docker's default capability set includes
`NET_RAW`. If you run the container with `--cap-drop=ALL`, add
`--cap-add=NET_RAW` to keep ICMP probes working. TCP and HTTP/HTTPS probes
need no special privileges.
```bash
sudo systemctl daemon-reload
```
If editing or reloading fails, stop. Repeat the read-only property check above
to confirm the intended ambient grant and preserved ceiling/hardening. Reload
changes systemd's configuration, **not the running process's capabilities**.
Schedule the restart as maintenance, outside backups or other guest operations.
**Pulse monitoring and alerts are unavailable during the restart**; arrange
independent outage coverage. Only after the checks pass, for the server that
was active beforehand:
```bash
sudo systemctl restart pulse.service && \
timeout --signal=TERM --kill-after=1s 8s systemctl is-active pulse.service
```
`active` proves service startup, not ICMP recovery or notification delivery.
Check the next normally scheduled observation from the **same location**;
do not induce a workload failure or another notification to prove recovery.
A failed/unknown restart needs normal recovery, not repeated restarts. To undo
this change, edit only the added line, preserve other drop-in content and
reload; do not use `systemctl revert`, which can remove unrelated overrides.
Apply any further restart only under the same maintenance and prior-active
conditions. Preserve the data directory and encryption key throughout.
#### Containers and external agents
Docker's default capability set includes `NET_RAW`, but that does not prove ICMP
works in every rootless, non-root or restricted container. For an intentionally
restricted deployment, review only the selected observation host's ping/socket
permissions and saved deployment. Do not use `--privileged`, `--cap-add=ALL`,
disable user namespaces or recreate a container as a diagnostic. A deliberate
capability change must use the deployment's normal maintenance route while
preserving data and configuration; the systemd override above does not apply.
External agents have their own OS/service requirements; see
[Agent Security](AGENT_SECURITY.md). Do not grant command execution or widen
agent privileges merely because the server's ICMP check failed. TCP and
HTTP/HTTPS checks need no raw-socket capability, but measure different behaviour;
switching protocol or observation location is not proof that ICMP recovered.
---
+44
View File
@@ -979,6 +979,50 @@ private, and report evidence as unavailable if safe collection is not possible.
QNAP and Unraid log locations are described in the
[space troubleshooting section](#installer-fails-with-not-enough-free-disk-space).
### Agent lookup returns HTTP 401 or 403
At `/api/agents/agent/lookup`, Pulse authenticates the request **before**
searching for the host. **HTTP 401** is an authentication rejection;
**HTTP 403** with `missing_scope` means the authenticated token lacks
`agent:report`. An authenticated lookup can return **HTTP 404** when no matching
host exists. A 401 alone does not identify a mistyped, expired or revoked token,
the wrong Pulse instance, or an upstream authentication layer.
The silent token prompt only keeps the secret out of the copied command and
shell history; it does not change reporting permissions. The normal
**Settings → Infrastructure → Install on a host** flow issues a monitoring
token. It does not use `--enroll`; the separate cluster deployment flow exchanges
a bootstrap token for a runtime token. Do not substitute a bootstrap token or
assume an enrolment-specific correction applies to a normal host installation.
Use the [bounded agent log reader](#collect-agent-logs-safely) on the affected
agent host, or inspect an already retained excerpt privately. Distinguish:
- `remote_config_client` reporting a lookup failure: this concerns configuration
retrieval and does not establish whether ordinary host reports were accepted.
- **Pulse rejected this agent's API token** from host reporting: keep its HTTP
status and redacted error, separately from the lookup failure.
A running local service or successful-looking installer output does not prove
that Pulse accepted a fresh report. No host-report error in the selected log
window is inconclusive, not proof of successful reporting. Do not restart,
re-enrol, delete identity or enable debug logging to manufacture that evidence.
In your signed-in administrator session on the **same Pulse instance** the
agent targets, check the intended record under **Settings → Security → API
Tokens** without revealing or copying its secret. Its presence, expiry and
reporting scope help distinguish server-side rejection, but do not prove that
the running service is using that record. Keep service environments, token
files and connection files private; do not paste a credential into a command,
URL or issue. Do not grant administrator scope, change Proxmox permissions or
disable authentication/TLS to test a guess.
If reporting this problem, say whether the existing error is lookup-only or
also affects host reporting, and share only the failure stage, HTTP status and
manually redacted error line. Keep the installation and saved credentials
unchanged until the failing stage is understood; a 401 is not itself a reason
to reinstall or rotate every token.
### pfSense service disabled after a major upgrade
Pulse installs two service files on pfSense: the FreeBSD rc.d service at
@@ -9360,6 +9360,15 @@ registration, enrolment, install, update, removal, report identity and
continuity are unchanged, and an agent's own declared node link still
ignores exclusions.
A report-merge that names sources now picks links by the member that carries
the source instead of by the sources a link's folded side took in with it
(`internal/api/resourceapi/resources.go`, `ReportedManualLinkFolds`). An agent
that holds a Docker host and sits in a VM leaves the VM only when the agent's
own source is named, and a report naming only Docker leaves the agent in the
VM. A report naming every source or none still splits every link. Agent
registration, enrolment, install, update, removal, report identity and
continuity are unchanged.
### Windows installer acceptance engine and absence (8 October 2026)
Native installer parsing, service-removal failure controls and actual lifecycle
@@ -9395,3 +9404,13 @@ for report-merge. No synthetic telemetry, identity key, enrollment or service
link is added. `TestOperatorSplitOverridesProxmoxNodeAgentLink` checks that
the monitor and its re-ingested API seed retain the fold across all split
shapes, then survive pin persistence and a repeated split.
### Mock-mode metrics-target lookups ride the fixture structure revision
`internal/monitoring/monitor.go` changed only so the mock-mode unified view
records the fixture structure revision it was built at and
`MetricsTargetForResource` resolves mock-mode targets from that view while the
revision and link list hold (monitoring contract, "Mock-mode metrics-target
lookups ride the fixture structure revision"). Agent registration, enrolment,
install, update, removal, report identity and continuity are unchanged, and
real-mode resolution still goes through the live registry.
@@ -1155,6 +1155,37 @@ inspectability, or convert missing/stale evidence into health.
## Current State
### Webhook drafts survive unconfirmed persistence
The existing Notifications editor awaits an explicit successful acknowledgement
from its add/update owner before clearing a draft. The owner returns success
only after the API responds and the returned destination replaces or joins the
displayed inventory. A rejected or interrupted save keeps the same inputs,
masked fields, template, mention and tag/severity policy. Fixed inline guidance
does not claim the write was rejected: an interrupted response can follow a
committed create, so the operator checks saved destinations in another tab
before explicitly retrying. There is no editor-triggered automatic replay.
One pending editor save locks the draft, Cancel and the conflicting local list
controls, and subsequent Save calls do not invoke another mutation. Success
closes the editor; failure unlocks it for correction or deliberate cancellation.
The API, encrypted persistence/publication ordering, saved-value masking, Test
payload parity, delivery policy, SSRF/TLS and existing shared API-client retry
rules are unchanged. This is draft recovery, not exactly-once creation or proof
that ordinary notifications arrive.
`WebhookConfig.persistence.test.tsx` exercises the actual editor and mutation
owner through deferred create/update responses, failure, explicit retry,
canonical accepted response, cancellation and fixed callback-error guidance.
The deliberate-retry control defers its response separately: clearing the old
warning at retry start is not acknowledgement, and the draft stays locked until
the accepted response incorporates the destination and closes the editor.
`WebhookConfig.test.tsx` now acknowledges successful saves before expecting
editor dismissal. The local `webhook-save-acknowledgement` browser fixture
checks the same components with synthetic API transport, including desktop and
phone keyboard/touch journeys and another-tab reconciliation after a lost
response. It sends no real notification and establishes no installed outcome.
### Active Docker update pending age survives restart
A positive Docker image-update report reuses the matching active occurrence's
@@ -4298,6 +4329,27 @@ Formatting-only follow-up retains this warning-level contract. The production-co
browser matrix was rerun after formatting at desktop and narrow widths, including
all/critical/warning save/reload, cancel, and warning webhook creation.
### SMTP rate-limit preference persistence
The existing SMTP editor's rate limit is included by
`alertDestinationsModel.buildEmailConfigPayload` on every destination save.
Unrelated email edits and switching email off must preserve the loaded limit;
an explicitly edited limit must reach the existing Notifications API unchanged.
Zero remains the backend's default-limit sentinel, not an omitted or unlimited
value. Missing saved limits still use the existing UI default of 60 per minute.
SMTP masks, TLS settings, recipients, tags and severity preferences are unchanged.
This repairs a missing UI-to-API field, not the schema or transport policy.
Revealing switched-off email settings permits pointer and keyboard editing without
enabling email. The status toggle remains off and Send test email stays disabled.
`emailRateLimit.persistence.test.tsx` connects the actual SMTP editor, destination
mutation owner, payload builder and email API to synthetic wire assertions.
The two payload-shape suites retain strict key checks including `rateLimit`.
`browser-tests/email-rate-limit.cjs` checks save and readback on desktop and phone,
including unrelated edits, disabled email and legacy zero. These fixtures do not
send notifications or establish native SMTP throughput, release availability or
the cause of an existing delivery failure.
### Pulse Mobile push destination without an upsell
The Alerts destinations page renders the mobile push panel only when the
@@ -12033,3 +12033,15 @@ pairs with the identity-match exclusions. `400 Resource is not merged` and
so two linked agents, which share their only source, can be reported.
`TestContract_ResourceReportMergeReplacesOperatorLink` pins the response
shape and the link's removal for a VM and the agent linked into it.
A request that names `sources` undoes the links that join a member carrying
one of those sources to the rest of the merged resource, chosen by member
(`ReportedManualLinkFolds`) rather than by the sources a link's folded side
took in along with its own members. Naming the source of a leaf in a chain of
links no longer also splits the leaf's holder from the resource, and where
links form a cycle the named member is cut from every link that would rejoin
it. The request, status codes, response shape and the meaning of an empty
`sources` (every link) are unchanged, and no new endpoint, field or stored
decision is added: `exclusions` still counts one link pair each.
`TestResourceReportMergeSourceFilterDetachesChainMembers` reports a chain
through the handler and the REST listing.
@@ -2433,6 +2433,23 @@ artifact-selection behaviour.
the deterministic dev user/hash. `tests/integration/scripts/run-playwright.mjs`
owns the run-scoped `HOT_DEV_VERIFY_LOCK_FILE` handoff so overlapping browser
proof cannot reuse stale first-run credentials.
Its npm entrypoint must attempt the existing posttest teardown once whenever
pretest was launched, including a nonzero setup exit or a failed test launch.
Failed setup skips Playwright and retains its original exit; teardown failure
may fail success but cannot turn an earlier failure into success. The handoff
stays held through teardown and is removed only if this invocation wrote it
and its complete contents still match. A failed lock preparation starts
neither setup nor teardown, and a non-hot-dev invocation never removes a
pre-existing verification lock. This is an ownership observation, not a new
exclusive lock or protection against an independent writer racing unlink.
`tests/integration/scripts/run-playwright.test.mjs` executes the actual
entrypoint with controlled pretest, Playwright and posttest child processes,
retaining exit precedence, identical run/state handoffs, partial-runtime
cleanup and foreign/replaced-lock preservation. Script smoke discovers it
through `scripts/tests/test-e2e-runner-cleanup.sh`. These command-level
controls do not establish real managed-runtime teardown, descendant reaping,
catchable-signal recovery, Windows execution or browser acceptance; the
existing owned shell supervisor and native/browser proofs remain separate.
The managed and foreground hot-dev entrypoints must share one network-default
contract: local dev binds frontend and backend traffic to loopback by default
so installed LAN agents cannot accidentally treat a developer laptop as the
@@ -5712,6 +5729,23 @@ helper must also prove that storage-pool and physical-disk history cover the
suite's deepest seven-day chart window. The compose harness must seed that
same seven-day window by default instead of forcing every parallel shard to
build unrelated 90-day preview history.
Core E2E must admit that same authenticated default fixture once, through
`tests/integration/fixture-readiness.config.ts` and its dedicated spec, after
container startup and before either test tier. An absent opt-in, incomplete
inventory or insufficient pool/disk history fails the shard before suite work;
it must not become repeated per-test setup failures. The policy lives in
`tests/integration/scripts/default-mock-readiness.mjs`, consumed by the existing
authenticated helper, with unchanged 120/180-second phase waits and history
depth. Admission has no retry or ignored failure and a separate bounded report;
it does not replace later fixture checks or any product/browser assertion.
Completed stable-tier failure reports and JUnit must be uploaded before the
observational probation pass, so a later probation cancellation cannot suppress
those completed receipts. The job deadline, stable/probation selection and verdict
gate stay unchanged. `scripts/tests/test-e2e-fixture-admission.sh` covers every
required platform identity, both history scopes and depth, actual admission-spec
error/cleanup behavior, the workflow's fail-fast command and receipt ordering.
These controls prove fixture/gate orchestration, not the cause of a hosted
runtime fault, provider transience or native monitoring recovery.
Managed runtime recovery and browser bootstrap proofs therefore need to keep
helper coverage that demonstrates browser-shell request tracking remains
trustworthy when the same test also performs direct health or security-status
@@ -3530,6 +3530,73 @@ and `TestUnifiedResourceSnapshotWithLinksLeavesTheSharedSnapshotUnlinked` in
`TestMockUnifiedStateViewUsesCanonicalMockFixtureGraph` in
`internal/monitoring/canonical_guardrails_test.go` pins the call.
### Mock-mode metrics-target lookups ride the fixture structure revision
`Monitor.MetricsTargetForResource` is the lookup behind every windowed alert
metric: an alert pass resolves a target once per resource and metric
(`metricWindowPoints`). In mock mode it answers from the `metricsTargets` the
cached mock view captured when it was built (`mockMetricsTarget` in
`internal/monitoring/metrics_target_resolution.go`), and that view serves
lookups for as long as `mock.FixtureStructureRevision` and the operator link
list it was built with are unchanged. A metric tick advances
`mock.FixtureDataVersion` but moves neither the revision nor any input of a
target (`BuildMetricsTarget` reads a resource's type, its source mappings and
the technology, Docker container ID and disk identity of its facets, with the
resource's own `MetricsTarget` as fallback), so a warm cache no longer sends
the next lookup off to build an estate. Mock mode being toggled, the estate
being reconfigured or a link change rebuilds the view on the next lookup; as
before, two lookups that miss together can each build one, and a cold or
invalidated cache builds even for an ID the estate does not list. Once the
view is current an unknown ID resolves to nothing and falls to the raw
resource store. The view stays keyed on the data version for every other
consumer (broadcast, `/api/state`, `GetUnifiedReadStateOrSnapshot`), which
still rebuild it on a data-version miss.
The revision is read before the data version when a view is cached, and the
fixture advances its data version before its revision
(`advanceFixtureStructure` in `internal/mock/integration.go`). A reader that
sees a new revision therefore also sees the new data version and cannot cache
the previous estate's memoized snapshot under it. The interleaving cannot be
forced from outside, so `TestFixtureStructureRevisionOrderingContract` pins the
order of the writes and of the reads in source.
Keyed on the data version, a lookup after a tick built the view (about 0.5 s
of CPU for the default 1,776-resource estate on an idle core), and a build
slower than the 2-second tick published an already-stale view, so the next
lookup built again. Startup runs the first alert pass before the listener
opens, so on a starved host it never finished: on 2026-10-08 the startup
watchdog's dump on pulse-dev parked the main goroutine in
`evaluateMetricWindow` -> `MetricsTargetForResource` -> `currentUnifiedStateView`
and `/api/health` timed out. The same binary pinned to one core behind four
busy loops missed the view cache on 675 of 929 requests at a 16-second average
build and had not served by 606 s; with this change it served at 148 s (13 s
on the core alone). This was not a regression: builds from 1a094646d7 and
d2ac0c5112 stall identically under equal contention and both start in 14 to
16 seconds at normal load. The remaining time on a starved host is the other
consumers of the shared view, which rebuild it whenever they find it stale (on
the starved run, 42 view requests in a start of 194 s were all misses, from the
Docker-alert prune, the connection snapshots and the host-agent evaluation),
and the monitor's repeated full resource-store passes at startup. Loops that
still reach the data-version view once per item are unchanged: the mock storage
chart history (`resolveMockStorageTotal` per uncached pool), the demo patrol's
finding adds (`patrolResourceOperatorStateProvider`) and, with
`PULSE_MOCK_KEEP_REAL_POLLING`, the per-node polling lookups
(`linkedHostForNode`, `getHostAgentTemperatureForNode`).
`TestMockMetricsTargetLookupsDoNotRebuildTheViewAfterFixtureTicks` and
`TestMockMetricsTargetForUnknownIDDoesNotRebuildTheViewAfterFixtureTicks` fail on
the data-version key; `TestMockMetricsTargetFollowsAStructuralFixtureChange`,
`TestMockMetricsTargetFollowsAnOperatorLink`,
`TestMockMetricsTargetRebuildsWhenTheLinkListMoves` and
`TestMockMetricsTargetMatchesTheRegistryResolution` pin that the reused view is
replaced when the structure or the links change and answers as the registry
does. All six are in `internal/monitoring/monitor_host_agents_test.go`, with
their helpers in `internal/monitoring/metrics_target_resolution_test.go`.
`TestMetricTicksNeverChangeTheFixtureStructure` in
`internal/mock/platform_fixtures_test.go` pins the assumption itself: after each
tick of two cohort rotations the revision, the resource set and every
resolved target are unchanged.
### Saved quiet-hours policy before queue activation
`Monitor.New` binds `alerts.Manager.QuietHoursNotificationPolicy` to the
@@ -6777,3 +6844,28 @@ generation younger than two seconds. The decision takes effect in the next
loaded generation, not retroactively in the listing already served. The
fixture seed keeps default thresholds while the configured live adapter
retains its four-minute Proxmox threshold.
### Incomplete guest filesystem responses do not establish recovery
`GetVMFSInfo` preserves valid peer rows from a malformed array but returns an
error with them, through both single and cluster clients. The surviving rows
cannot establish total guest capacity, usage, or removal of a rejected volume.
A completed response with invalid counters is not a command-completion failure:
serial admission, same-target lock checks and single-attempt guest requests
remain unchanged, and there is no failover/replay or new cooldown. Alternate
clients' contradictory or out-of-range counters enforce the same completeness
boundary before publishing a current aggregate.
Monitoring retains only the prior complete disk tuple, mount inventory and
original observation time within the existing age bound; otherwise disk usage
is unavailable. Partial responses do not create disk History, resolve genuine
filesystem alerts, or remove unknown volumes. Independent current CPU/memory
continues. A later complete ordinary inventory can still remove a volume and
publish an explicitly measured zero, append History and recover alerts.
`pkg/proxmox/guest_filesystem_completeness_test.go` and
`internal/monitoring/guest_filesystem_completeness_test.go` cover valid peers plus
malformed records, both client/collector paths, canonical and served state,
original age/expiry, History, breach continuity and complete removal/recovery.
These are synthetic source controls, not native thaw, the cause of #2619/#2439,
or installed/released acceptance.
@@ -3724,6 +3724,35 @@ nonempty machine and agent IDs and canonical identity across fixture rebuilds.
This changes fixture generation only, without relaxing production identity
resolution or adding work to the recurring update path.
### Alert passes resolve metrics targets without materializing the estate
An alert pass makes one `Monitor.MetricsTargetForResource` call per resource
and windowed metric, so that call has to be a lookup, not a view build. In mock
mode it reads the targets the cached view captured and keeps reading them across
metric ticks (see "Mock-mode metrics-target lookups ride the fixture structure
revision" in the monitoring contract). On pulse-dev,
`BenchmarkMockMetricsTargetResolutionAfterTick` measured a pass of 4,731
lookups against the default estate, after a tick, at 1.8 ms and 151 KB. The
data-version key made the first lookup after every tick build the estate, and
made every lookup of a pass build it once a build outlasted the 2-second tick,
which stalled startup under CPU starvation. Real mode did not show this cost in
steady state: the store is itself the read state, and a one-off measurement
with a temporary counter (not asserted by a test) found a full
`updateResourceStore` pass over 1,694 resources (populate, metric syncs and
alert sync) taking 108 ms with 121 registry resolutions that fell back to the
full-mapping scan, because the registry's typed views are built before the
alert loop runs. `BenchmarkLiveRegistryMetricsTargetResolution` records only
the lookup cost of a registry whose views are unbuilt against one whose views
are built (136 ms against 5 ms per 5,079 lookups); it controls the warming
itself, so it cannot detect production failing to warm the views before the
alert loop, which is what the one-off measurement above showed. Run both
benchmarks with `-benchtime=3x`, since the registry case rebuilds its ingest
outside the timer. `TestMockMetricsTargetLookupsDoNotRebuildTheViewAfterFixtureTicks`
is the guard that fails on a rebuild per lookup. The remaining startup cost
under starvation is the monitor's repeated full resource-store passes (one per
supplemental provider registration) and the other consumers of the shared view,
which rebuild it whenever they find it stale; neither is changed here.
### Update evidence reuses the bounded node observation
Package evidence adds only one bounded status, one bounded reason, and the
@@ -6758,3 +6758,12 @@ links' pairs, which deletes the pair's link row in the resource store as an
unlink does. Nothing new is persisted: the record is rebuilt with each
registry generation, and no schema, backup, retention, migration or
recovery path is added or moved.
Each fold also records its two sides' own sources (`HolderOwn`, `FoldedOwn`),
in the same unexported in-memory field, and `ReportedManualLinkFolds` in
`internal/unifiedresources/manual_link_folds.go` uses them so a
source-filtered report-merge in `internal/api/resourceapi/resources.go`
excludes the links that join a member carrying the named source to the rest
of the merged resource. That changes which pairs the report excludes, through
the same exclusion write as before; no schema, backup, retention, migration or recovery path is added or
moved.
@@ -7643,14 +7643,37 @@ fold already recorded (the holder still carries what the earlier fold
brought). The record is unexported and rides in-memory
clones, so it reaches the resources API's registry, which seeds from the
monitor's already-linked listing and never holds the folded row.
Report-merge excludes the pair of every link whose folded side brought a
reported source category (`ResourceRegistry.ManualLinkFolds`), and the
store's one-decision-per-pair rule deletes that link row, so the pair splits
on every surface from the monitor's next rebuild, as with unlink. The drawer
reports every merged source. Categories are not attributed to individual
contributors: naming one that some folded side carried undoes that link even
where the holder carries it too, and a source that arrived along a chain of
links undoes every link on its way to the merged resource.
A fold's `Sources` is its folded side's whole subtree, so it cannot say
which member of a chain a source belongs to. Selecting links by it undid every
link on the way to a reported source (a Docker host linked into an agent
linked into a VM: naming Docker split the agent from the VM too), and where
links form a cycle it left the reported member joined through the link it did
not name. Each fold therefore also records what its two sides are on their own
(`HolderOwn`, `FoldedOwn`): the sources the side's row listed before any link
folded something into it, which a side that already took members in reads from
the record its first fold made. `recordManualLinkFold` runs before the other
side is merged for that reason, and a repeated pair unions the own sources as
it does `Sources`.
Report-merge picks links by member (`ReportedManualLinkFolds`, fed by
`ResourceRegistry.ManualLinkFolds`). A member whose own sources include a
reported source is reported, whatever it took in; the resource being reported
on never is, since it is what stays. A link is undone when it joins a reported
member to a member that stays, one the resource still reaches without passing
through a reported member, or to another reported member. A member that hangs
below a reported one and carries no reported source goes with it, and the link
holding it there stays: the report names a source that is not theirs to undo.
Where links form a cycle, a reported member linked both to the resource and to
a member that stays is cut from both, so the surviving link cannot fold it
back in. The store's one-decision-per-pair rule deletes each undone link row,
so the pair splits on every surface from the monitor's next rebuild, as with
unlink. The drawer reports every merged source, which undoes every link, and
so does a report naming none. A fold recorded without its sides' own sources
falls back to its folded subtree, and a member several folds name takes the
union of what they record, since a refold unions into its own pair's fold
only. A resource the records no longer name (re-keyed after the links folded)
is replaced by the one holder no link folded; when no single holder qualifies,
which the recorder does not produce, the folds' subtrees decide, as before.
A link joins only the pair its row names. Canonical-ID succession re-keys
link endpoints with `UPDATE OR IGNORE` but moves `primary_id`
@@ -7678,8 +7701,18 @@ monitor's registry and on one seeded from its listing, and
`TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests` folds a TrueNAS
system into a VM across repeated record ingests, and
`TestManualLinkFoldRepeatedPairKeepsEarlierSources` refolds a side with fewer
sources than it first brought. `TestResourceAPIReportMergeExcludesRegistryLinkFolds`
keeps the handler on the registry's fold record.
sources than it first brought. `TestManualLinkFoldOwnSourcesComeFromTheFirstRecord`
keeps a holder's own sources from growing with what it takes in.
`TestReportedManualLinkFoldsDetachMembersCarryingTheSource` selects links for a
leaf, a holder, both and a cycle (a TrueNAS VM taking in a Proxmox storage and
a vSphere VM that is also linked to the storage), and
`TestReportedManualLinkFoldsLeaveNoReportedMemberJoined` checks that no
reported member stays joined to the resource through the links left, which the
subtree rule fails on that cycle. `TestResourceReportMergeSourceFilterDetachesChainMembers`
reports a Docker host linked into an agent linked into a VM through the API,
for the leaf, the holder, both, every source and no filter, on the monitor and
the REST surface. `TestResourceAPIReportMergeExcludesRegistryLinkFolds` keeps
the handler on the registry's fold record.
### Reconciled node-agent relink fold bookkeeping
@@ -0,0 +1,258 @@
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/frontend-modern';
const output = '/workspace/tmp/email-rate-limit-proof';
const runtimePath = 'frontend-modern/src/features/alerts/alertDestinationsModel.ts';
const runtimePaths = [
runtimePath,
'frontend-modern/src/features/alerts/AlertEmailDestinationsSection.tsx',
];
const baseEmail = {
enabled: true,
provider: '',
server: 'smtp.example.test',
port: 587,
username: 'ops@example.test',
password: '***REDACTED***',
from: 'pulse@example.test',
to: ['alerts@example.test'],
tls: false,
startTLS: true,
rateLimit: 17,
tagFilter: ['production'],
tagFilterMode: 'any',
minimumSeverity: 'warning',
};
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const digests = Object.fromEntries(
runtimePaths.map((file) => [
file,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', file)))
.digest('hex'),
]),
);
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(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5294, strictPort: true },
});
const results = [];
let browser;
let serverClosed = false;
try {
await server.listen();
for (const [name, engine, width, height, dark] of [
['desktop-light', chromium, 1365, 900, false],
['desktop-dark', chromium, 1365, 900, true],
['phone-dark', webkit, 390, 844, true],
['narrow-light', webkit, 320, 844, false],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const browserVersion = browser.version();
const page = await browser.newPage({
viewport: { width, height },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
colorScheme: dark ? 'dark' : 'light',
});
page.setDefaultTimeout(15000);
let email = { ...baseEmail };
const writes = [];
const unexpected = [];
const errors = [];
const checks = [];
const captures = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.addInitScript((isDark) => {
if (!isDark) return;
const apply = () => document.documentElement.classList.add('dark');
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5294') {
unexpected.push('off-origin request');
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
const key = `${request.method()} ${url.pathname}`;
let body;
if (key === 'GET /api/notifications/email') body = email;
else if (key === 'GET /api/notifications/email-providers') body = [];
else if (key === 'GET /api/notifications/webhooks') body = [];
else if (key === 'GET /api/notifications/apprise') body = { enabled: false, targets: [] };
else if (key === 'GET /api/alerts/deadman/config')
body = { pingUrl: '', configured: false };
else if (key === 'GET /api/health') body = { csrfToken: 'fixture-only' };
else if (key === 'PUT /api/notifications/email') {
const submitted = request.postDataJSON();
assert.equal(submitted.password, baseEmail.password);
assert.equal(submitted.tls, false);
assert.equal(submitted.startTLS, true);
assert.deepEqual(submitted.tagFilter, ['production']);
assert.equal(submitted.tagFilterMode, 'any');
assert.equal(submitted.minimumSeverity, 'warning');
writes.push({ rateLimit: submitted.rateLimit, enabled: submitted.enabled });
// Model the existing default only in the fixture, not in the client.
email = { ...submitted, rateLimit: submitted.rateLimit ?? 60 };
body = { success: true };
} else if (key === 'PUT /api/notifications/apprise') {
body = request.postDataJSON();
} else if (key === 'PUT /api/alerts/deadman/config') {
body = { success: true, configured: false };
} else {
unexpected.push(key);
return route.abort();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
const open = async () => {
await page.goto('http://127.0.0.1:5294/browser-tests/email-rate-limit.html');
await page.getByRole('button', { name: 'Save changes' }).waitFor();
assert.equal(
await page.evaluate(() => document.documentElement.classList.contains('dark')),
dark,
);
};
const advanced = async () => {
await page.getByRole('button', { name: 'Show advanced options' }).click();
return page.getByRole('spinbutton', { name: 'Rate limit' });
};
const save = async (expectedLimit) => {
const before = writes.length;
await page.getByRole('button', { name: 'Save changes' }).click();
await page
.getByRole('status')
.filter({ hasText: /^Saved$/ })
.waitFor();
assert.equal(writes.length, before + 1);
assert.equal(writes.at(-1).rateLimit, expectedLimit);
assert.equal(email.rateLimit, expectedLimit);
};
const check = async (label, run) => {
await run();
checks.push(label);
};
const capture = async (state) => {
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
'no horizontal overflow',
);
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
captures.push(file);
};
await check('loaded non-default limit is visible', async () => {
await open();
assert.equal(await (await advanced()).inputValue(), '17');
});
await capture('loaded');
await check('unrelated edit preserves limit, mask, TLS and routing on the wire', async () => {
await page
.getByRole('textbox', { name: 'From address', exact: true })
.fill('new@example.test');
await save(17);
});
await check('unrelated edit survives a reload with the same limit', async () => {
await open();
assert.equal(await (await advanced()).inputValue(), '17');
assert.equal(
await page.getByRole('textbox', { name: 'From address', exact: true }).inputValue(),
'new@example.test',
);
});
await check('edited limit is sent exactly once', async () => {
await page.getByRole('spinbutton', { name: 'Rate limit' }).fill('12');
await save(12);
});
await capture('saved');
await check('edited limit is read back after reload', async () => {
await open();
assert.equal(await (await advanced()).inputValue(), '12');
});
await check('switching email off preserves its limit', async () => {
await page.getByRole('button', { name: 'Email notifications Enabled' }).click();
await save(12);
assert.equal(email.enabled, false);
});
await check('switched-off settings read back the same limit', async () => {
await open();
await page.getByRole('button', { name: 'Show settings', exact: true }).click();
assert.equal(await (await advanced()).inputValue(), '12');
assert.equal(
await page.getByRole('button', { name: 'Send test email' }).isDisabled(),
true,
);
await page.getByRole('spinbutton', { name: 'Rate limit' }).fill('9');
await save(9);
assert.equal(email.enabled, false);
});
await capture('off');
await check('legacy default sentinel zero remains zero in the saved payload', async () => {
email = { ...baseEmail, rateLimit: 0 };
await open();
await save(0);
});
await check('no test, retry, dismissal, off-origin request or browser error', async () => {
assert.deepEqual(unexpected, []);
assert.deepEqual(errors, []);
assert.equal(writes.length, 5);
});
results.push({ name, browserVersion, width, height, dark, checks, writes, captures });
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
serverClosed = true;
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: results.length === 4 ? 'passed' : 'incomplete',
playwrightVersion,
content_sha256: digests,
browserClosed: true,
serverClosed,
results,
},
null,
2,
) + '\n',
);
}
console.log(JSON.stringify({ result: 'passed', groups: 36, captures: 12, serverClosed }));
})().catch((error) => {
console.error(error);
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>SMTP rate-limit persistence verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./email-rate-limit.tsx"></script>
</body>
</html>
@@ -0,0 +1,54 @@
// Existing SMTP editor and mutation owner with fixture-only persisted responses.
// No notification, retry, dismissal or licensed/native operation is performed.
import { Show, createSignal, onMount } from 'solid-js';
import { render } from 'solid-js/web';
import { AlertEmailDestinationsSection } from '../src/features/alerts/AlertEmailDestinationsSection';
import { useAlertDestinationsState } from '../src/features/alerts/useAlertDestinationsState';
import '../src/index.css';
function Fixture() {
const state = useAlertDestinationsState({ activeTab: () => 'destinations' });
const [ready, setReady] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [status, setStatus] = createSignal('');
onMount(async () => {
await state.loadDestinations();
setReady(true);
});
return (
<main class="mx-auto max-w-4xl p-3 text-base-content">
<h1 class="mb-4 text-xl font-semibold">Notifications</h1>
<Show when={ready()}>
<AlertEmailDestinationsSection
config={state.emailConfig()}
setConfig={state.setEmailConfig}
setHasUnsavedChanges={() => setStatus('Unsaved changes')}
onTest={() => {
throw new Error('Sending a test notification is outside this fixture');
}}
testing={false}
/>
<button
class="mt-3 rounded-md bg-blue-600 px-4 py-2 text-white"
disabled={saving()}
onClick={async () => {
setSaving(true);
try {
await state.saveDestinations();
setStatus('Saved');
} catch {
setStatus('Save failed');
} finally {
setSaving(false);
}
}}
>
Save changes
</button>
<p role="status">{status()}</p>
</Show>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -7,7 +7,10 @@ import '../src/index.css';
function Fixture() {
const [operations, setOperations] = createSignal(0);
const unexpectedOperation = () => setOperations((count) => count + 1);
const unexpectedOperation = () => {
setOperations((count) => count + 1);
return false;
};
return (
<main class="mx-auto min-h-screen max-w-4xl space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Webhook setup</h1>
@@ -0,0 +1,382 @@
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/frontend-modern';
const output = '/workspace/tmp/webhook-save-proof/browser';
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
(async () => {
const binding = JSON.parse(
fs.readFileSync('/workspace/tmp/webhook-save-proof/browser-binding.json'),
);
for (const [file, digest] of Object.entries(binding.content_sha256)) {
assert.equal(hash(path.join('/workspace', file)), digest);
}
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
fs.mkdirSync(output, { 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(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5310, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5310';
const results = [],
screenshots = [],
browserVersions = [];
let browser,
phase = 'server',
browserClosed = false,
serverClosed = false,
passed = false;
try {
await server.listen();
for (const [name, engine, width, height, dark] of [
['desktop-light', chromium, 1365, 900, false],
['desktop-dark', chromium, 1365, 900, true],
['phone-dark', webkit, 390, 844, true],
['narrow-light', webkit, 320, 740, false],
]) {
browserClosed = false;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
browserVersions.push({
name,
engine: engine === chromium ? 'chromium' : 'webkit',
version: browser.version(),
});
const context = await browser.newContext({
viewport: { width, height },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
});
await context.addInitScript((dark) => {
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
const saved = {
id: 'saved-hook',
name: 'Existing destination',
url: 'https://example.test/receiver',
method: 'POST',
enabled: true,
service: 'generic',
headers: { Authorization: '********' },
customFields: { team: 'ops' },
template: '{"message":"{{message}}"}',
mention: 'operators',
tagFilter: ['department-a'],
tagFilterMode: 'any',
minimumSeverity: 'warning',
};
let inventory = [saved],
mode = 'pending',
release;
const writes = [],
unexpected = [],
errors = [],
checks = [];
await context.routeWebSocket(/.*/, (socket) => {
const url = new URL(socket.url());
if (url.origin === 'ws://127.0.0.1:5310' && url.pathname === '/') socket.connectToServer();
else {
unexpected.push({ type: 'websocket' });
socket.close();
}
});
await context.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
unexpected.push({ type: 'off-origin' });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (req.method() === 'GET') {
if (url.pathname === '/api/health') return route.fulfill({ json: { status: 'healthy' } });
if (url.pathname === '/api/notifications/webhooks')
return route.fulfill({ json: inventory });
if (url.pathname === '/api/notifications/webhook-templates')
return route.fulfill({ json: [] });
}
if (
(req.method() === 'POST' && url.pathname === '/api/notifications/webhooks') ||
(req.method() === 'PUT' && url.pathname === '/api/notifications/webhooks/saved-hook')
) {
const write = { method: req.method(), path: url.pathname, payload: req.postDataJSON() };
writes.push(write);
if (mode === 'pending')
await new Promise((resolve) => {
release = resolve;
});
if (mode === 'failure')
return route.fulfill({
status: 500,
json: { error: 'Synthetic configuration save unavailable' },
});
const accepted = {
...write.payload,
id: req.method() === 'POST' ? `new-hook-${writes.length}` : 'saved-hook',
headers: { Authorization: '********' },
};
inventory =
req.method() === 'POST'
? [...inventory, accepted]
: inventory.map((hook) => (hook.id === accepted.id ? accepted : hook));
if (mode === 'lost-response') return route.abort('failed');
return route.fulfill({ json: accepted });
}
unexpected.push({ method: req.method(), path: url.pathname });
return route.fulfill({
status: 403,
json: { error: 'Fixture refuses Test, deletion and unrelated operations' },
});
});
results.push({ name, checks, writes, unexpected, errors });
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
page.setDefaultTimeout(8000);
page.setDefaultNavigationTimeout(45000);
const check = async (label, action) => {
phase = `${name}: ${label}`;
await action();
checks.push(label);
};
const click = async (locator) => {
if (width < 500) await locator.tap();
else {
await locator.focus();
await page.keyboard.press('Enter');
}
};
const capture = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file) });
screenshots.push({
file,
state,
sha256: hash(path.join(output, file)),
viewport: { width, height },
});
};
const noOverflow = async () =>
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
);
phase = `${name}: load actual editor`;
await page.goto(`${origin}/browser-tests/webhook-save-acknowledgement.html`);
await page.getByText('Existing destination', { exact: true }).first().waitFor();
await click(page.getByRole('button', { name: '+ Add Webhook', exact: true }));
await page.getByLabel('Name', { exact: true }).fill('New destination');
await page.getByLabel('Webhook URL', { exact: true }).fill('https://example.test/new');
await page
.getByLabel('Custom header 1 value', { exact: true })
.fill('application/custom+json');
await page.getByLabel('Minimum alert severity', { exact: true }).selectOption('warning');
await click(page.getByRole('button', { name: 'Add Webhook', exact: true }));
await check(
'pending save keeps draft, locks conflicting actions and admits one write',
async () => {
await page.getByRole('button', { name: 'Saving…', exact: true }).waitFor();
assert.equal(
await page.getByLabel('Name', { exact: true }).inputValue(),
'New destination',
);
assert.equal(await page.getByLabel('Name', { exact: true }).isEnabled(), false);
for (const label of ['Saving…', 'Cancel', 'Edit', 'Delete', 'Enabled', 'Disable All'])
assert.equal(
await page.getByRole('button', { name: label, exact: true }).isEnabled(),
false,
);
await page.getByRole('button', { name: 'Saving…' }).evaluate((button) => {
button.click();
button.click();
});
await page.waitForFunction(
() => document.querySelector('fieldset')?.getAttribute('aria-busy') === 'true',
);
assert.equal(writes.length, 1);
await noOverflow();
},
);
await capture('pending');
mode = 'failure';
release();
await check(
'failed create retains values and announces fixed uncertainty, without a replay',
async () => {
await page.getByRole('alert').waitFor();
assert.ok((await page.getByRole('alert').innerText()).includes('another tab'));
assert.equal(
await page.getByLabel('Custom header 1 value').inputValue(),
'application/custom+json',
);
assert.equal(
await page.getByLabel('Minimum alert severity', { exact: true }).inputValue(),
'warning',
);
assert.equal(await page.getByLabel('Name', { exact: true }).isEnabled(), true);
assert.equal(writes.length, 1);
await noOverflow();
},
);
await page.getByRole('alert').scrollIntoViewIfNeeded();
await capture('failed-create');
mode = 'success';
await click(page.getByRole('button', { name: 'Add Webhook', exact: true }));
await check('explicit create retry closes only after accepted response', async () => {
await page.getByRole('button', { name: '+ Add Webhook', exact: true }).waitFor();
assert.equal(
await page.getByLabel('Saved destination names').innerText(),
'Existing destination, New destination',
);
assert.equal(writes.length, 2);
assert.deepEqual(writes[0].payload, writes[1].payload);
assert.equal(await page.getByRole('alert').count(), 0);
});
await click(page.getByRole('button', { name: 'Edit', exact: true }).first());
await page.getByLabel('Name', { exact: true }).fill('Edited destination');
mode = 'failure';
await click(page.getByRole('button', { name: 'Update Webhook', exact: true }));
await check('failed edit keeps masking, template, tag and severity policy', async () => {
await page.getByRole('alert').waitFor();
assert.equal(await page.getByLabel('Custom header 1 value').inputValue(), '********');
assert.equal(
await page.getByLabel('Name', { exact: true }).inputValue(),
'Edited destination',
);
assert.equal(
await page.getByLabel('Minimum alert severity', { exact: true }).inputValue(),
'warning',
);
const payload = writes[2].payload;
for (const key of [
'headers',
'customFields',
'template',
'mention',
'tagFilter',
'tagFilterMode',
'minimumSeverity',
])
assert.deepEqual(payload[key], saved[key]);
assert.equal(
await page.getByLabel('Saved destination names').innerText(),
'Existing destination, New destination',
);
await noOverflow();
});
await page.getByRole('alert').scrollIntoViewIfNeeded();
await capture('failed-edit');
mode = 'success';
await click(page.getByRole('button', { name: 'Update Webhook', exact: true }));
await check('accepted edit replaces inventory with acknowledged response', async () => {
await page.getByRole('button', { name: '+ Add Webhook', exact: true }).waitFor();
assert.equal(
await page.getByLabel('Saved destination names').innerText(),
'Edited destination, New destination',
);
assert.deepEqual(writes[2].payload, writes[3].payload);
});
await click(page.getByRole('button', { name: '+ Add Webhook', exact: true }));
await page.getByLabel('Name', { exact: true }).fill('Unconfirmed destination');
await page
.getByLabel('Webhook URL', { exact: true })
.fill('https://example.test/unconfirmed');
mode = 'lost-response';
await click(page.getByRole('button', { name: 'Add Webhook', exact: true }));
await check('lost response leaves the draft and makes no unrequested duplicate', async () => {
await page.getByRole('alert').waitFor();
assert.equal(
await page.getByLabel('Name', { exact: true }).inputValue(),
'Unconfirmed destination',
);
assert.equal(writes.length, 5);
assert.ok(!(await page.getByRole('alert').innerText()).includes('not saved'));
const other = await context.newPage();
await other.goto(`${origin}/browser-tests/webhook-save-acknowledgement.html`);
await other.getByText('Unconfirmed destination', { exact: true }).first().waitFor();
assert.ok(
(await other.getByLabel('Saved destination names').innerText()).includes(
'Unconfirmed destination',
),
);
await other.close();
assert.equal(writes.length, 5);
assert.equal(new Set(inventory.map((hook) => hook.id)).size, inventory.length);
await noOverflow();
});
await page.getByRole('alert').scrollIntoViewIfNeeded();
await capture('lost-response');
await click(page.getByRole('button', { name: 'Cancel', exact: true }));
await click(page.getByRole('button', { name: '+ Add Webhook', exact: true }));
await check(
'deliberate Cancel clears old draft and error without another write',
async () => {
assert.equal(await page.getByLabel('Name', { exact: true }).inputValue(), '');
assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(writes.length, 5);
assert.deepEqual(unexpected, []);
assert.deepEqual(errors, []);
await noOverflow();
},
);
await browser.close();
browserClosed = true;
}
passed = true;
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify({ phase, error: String(error) }, null, 2),
);
throw error;
} finally {
if (browser && !browserClosed) {
await browser.close();
browserClosed = true;
}
await server.close();
serverClosed = true;
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: passed ? 'passed' : 'failed',
phase,
base_sha: binding.base_sha,
content_sha256: binding.content_sha256,
playwrightVersion,
browserVersions,
results,
screenshots,
browserClosed,
serverClosed,
scope:
'Actual existing Webhook section/editor/mutation owner and API client with synthetic transport. No provider/notification, native persistence, deployment or release acceptance.',
},
null,
2,
),
);
}
})().catch((error) => {
process.stderr.write(`${error.stack || error}\n`);
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>Webhook save acknowledgement verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./webhook-save-acknowledgement.tsx"></script>
</body>
</html>
@@ -0,0 +1,32 @@
// Actual destination section, editor, API client and mutation owner. The runner
// supplies synthetic local responses; no notification, provider or saved host.
import { render } from 'solid-js/web';
import { AlertWebhookDestinationsSection } from '../src/features/alerts/AlertWebhookDestinationsSection';
import { useAlertWebhookDestinationsState } from '../src/features/alerts/useAlertWebhookDestinationsState';
import '../src/index.css';
function Fixture() {
const state = useAlertWebhookDestinationsState();
return (
<main class="mx-auto min-h-screen max-w-4xl space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Notification destinations</h1>
<p class="text-sm text-muted">Offline save-recovery check. No notification is sent.</p>
<AlertWebhookDestinationsSection
webhooks={state.webhooks()}
addWebhook={state.addWebhook}
updateWebhook={state.updateWebhook}
deleteWebhook={state.deleteWebhook}
testWebhook={state.testWebhook}
testingWebhook={state.testingWebhook()}
/>
<output aria-label="Saved destination names" class="block text-sm">
{state
.webhooks()
.map((hook) => hook.name)
.join(', ')}
</output>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,114 @@
{
"version": 1,
"base_sha": "27576b90ff416cd16d1ef744cd299b83af4a2f36",
"verified_at": "2026-10-10T03:40:27.414721Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/AlertEmailDestinationsSection.tsx",
"frontend-modern/src/features/alerts/alertDestinationsModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/alerts/alertDestinationsModel.ts": "1af3cc1bfcc6202d1033f56f8982a1f785a65dc93b2fd1734e1cfe124748e18c",
"frontend-modern/src/features/alerts/AlertEmailDestinationsSection.tsx": "131354cd4a7c835973173ea391a01d912e58cad0969f520c2dfb805a984bcb47"
},
"routes": [
"Existing Alerts Notifications SMTP section via browser-tests/email-rate-limit.html (fixture only)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"states": [
"Loaded non-default limit, saved unrelated edit, edited limit with reload, switched-off email with inspectable/editable settings, explicit legacy zero sentinel. Desktop light/dark and WebKit phone/narrow light."
],
"interactions": [
"Actual Email section, editor, destination mutation owner, payload builder and API client against synthetic persisted GET/PUT responses.",
"Each viewport preserves loaded 17 through an unrelated edit, saves 12 and reloads it, switches email off then inspects/edits/saves 9 without enabling email or the Test button, and preserves zero on the wire.",
"Thirty-six assertion groups and twelve individually inspected full-page captures. Masks, STARTTLS, tag routing and warning severity remain unchanged. No Test, retry, dismissal, Relay/native operation or external/provider request."
],
"screenshots": [
{
"file": "desktop-light-loaded.png",
"sha256": "bec915910540fd8f0936531b5e5a47723baf2eeeac0be774b839a3b5738da4da"
},
{
"file": "desktop-light-saved.png",
"sha256": "555902f2581fff7237f187fe9d34908ad5c1c1dc06f11ce3e856012f3c974455"
},
{
"file": "desktop-light-off.png",
"sha256": "54a29af969607ac81f63dee0c1120b7a508023bdba154cf790b1d31cb5153811"
},
{
"file": "desktop-dark-loaded.png",
"sha256": "f9ca2e82f774dbeee06f8d310da6832bbb612d501bed7e72fd7ac7c1da0d15ef"
},
{
"file": "desktop-dark-saved.png",
"sha256": "47575ae1a3fe600054e2dbb5a42f93b7d13049495b7b656ba1c9a8b915110a24"
},
{
"file": "desktop-dark-off.png",
"sha256": "2f0186f278b17996175e564b98c42670fbc01f393b4b7d0a7941ccc2324086c2"
},
{
"file": "phone-dark-loaded.png",
"sha256": "51bfbe5bda8b7a0a434a8c7de684c76b4995c6ab265fc3ffda66a3836c7482da"
},
{
"file": "phone-dark-saved.png",
"sha256": "9f70f6c89242953b9e301b70f094c01b3cf3dae4fc2b5be831f39eb3106eb89d"
},
{
"file": "phone-dark-off.png",
"sha256": "689c31341391f6102646d60a43e58089754ba11251d1725378fa2fd25e3ea4ba"
},
{
"file": "narrow-light-loaded.png",
"sha256": "43bbf97b82cd562d151f80c04e8cc75d3b74003c5330c3533a923ca0a8a1aea1"
},
{
"file": "narrow-light-saved.png",
"sha256": "f48237f0ebe5a6365cae3c9b52c7a86efe71660cb733bc4301818f2c5cd9fa24"
},
{
"file": "narrow-light-off.png",
"sha256": "902b2cb9af2dba6429c1a64824a4b9dd9bc9962c271dc4b7bf4314eddf5f3d38"
}
],
"tools": {
"playwright": "1.56.1",
"browser_versions": [
{
"name": "desktop-light",
"version": "141.0.7390.37"
},
{
"name": "desktop-dark",
"version": "141.0.7390.37"
},
{
"name": "phone-dark",
"version": "26.0"
},
{
"name": "narrow-light",
"version": "26.0"
}
]
}
}
@@ -0,0 +1,52 @@
{
"version": 1,
"base_sha": "fcaaabe25bbd8766c2eea6f72fbac62b92ea26a0",
"result": "passed",
"verified_at": "2026-10-10T02:54:43Z",
"changed_paths": [
"frontend-modern/src/components/Alerts/WebhookConfig.tsx",
"frontend-modern/src/components/Alerts/WebhookConfigForm.tsx",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts",
"frontend-modern/src/features/alerts/AlertWebhookDestinationsSection.tsx",
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/WebhookConfig.tsx": "bdc26fd7e7ff60c4e5dee07e65a684cd730e1b840c0a4074c40a3d3127f8734f",
"frontend-modern/src/components/Alerts/WebhookConfigForm.tsx": "97ca12ed9c7e7c0fe93a6d5c9eb0daed596ce859fd884f2f56316ab2d91512d3",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx": "3caf78928a5b28947eaba68a335aa7404262d0cafba3a37cefb8095fafc9b545",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "b3f3888bc6a87c2894ccf40dd936a4076a7b910982743037deb57730f294996f",
"frontend-modern/src/features/alerts/AlertWebhookDestinationsSection.tsx": "002609ca384cec8e6275392ce1c0617088ad64c9f06e621edf93bfe11818ad5b",
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts": "50e7843784251c2d47cdb20886df9f94b1a066f2dac788234a129bd4ae1a7184"
},
"routes": [
"Existing Notifications webhook section in local /browser-tests/webhook-save-acknowledgement.html",
"Existing Notifications editor and real mutation owner/API client; no shipped new route"
],
"states": [
"Pending create, disabled editor/conflicting list controls, one request",
"Failed create/update preserves the same draft and masks/template/routing",
"Accepted create/update incorporates canonical response before clearing draft",
"Accepted synthetic create followed by lost response: draft retained, another tab confirms stored fixture without duplicate",
"Explicit retry and deliberate cancellation; desktop light/dark and phone dark/narrow light"
],
"interactions": [
"28 assertion groups: keyboard Enter and phone touch saves/cancel, request counts/payloads, failed-save recovery, masked update, another-tab reconciliation",
"16 inspected screenshots; fixed inline warning readable, fieldsets/controls remain within 320px viewport; lower phone footer reached by scrolling before each interaction"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 740
}
],
"notes": "Playwright 1.56.1; Chromium 141.0.7390.37 and WebKit 26.0; browser/server closed. Actual existing components with synthetic local API responses. No notification/native persistence/provider/delivery/release acceptance. Earlier label/CSRF-setup fixture failures retained separately."
}
+99 -16
View File
@@ -634,39 +634,122 @@ Version fields are returned as plain semantic versions (no leading `v`).
Returns a small public config payload (update channel, auto-update enabled).
### List Nodes
`GET /api/config/nodes`
`GET /api/config/nodes` (admin, `settings:read`)
This administrative inventory covers saved **PVE, PBS and PMG connections**,
not individual VMs or agents. Keep its response private. Use the returned
connection `id` for updates, deletion and tests; a display name, VMID or another
installation's node name is not that ID. Re-read the inventory before a change
rather than guessing an ID from a list position.
### Add Node
`POST /api/config/nodes`
`POST /api/config/nodes` (admin, `settings:write`)
Adding a connection saves credentials and enables collection; it is not a
passive validation step. Use an existing dedicated, least-privilege platform
token. The **Pulse API token** in the private header file authorises this
configuration request; the **Proxmox token** in the JSON body authorises reads
from the monitored platform. They are different credentials. Do not use a root
password or widen a department-scoped token to make denied peer reads succeed;
follow the [scoped-access checks](TROUBLESHOOTING.md#check-permissions-proxmox).
For PBS and PMG, use their [PBS](PBS.md#method-1-api-only-connection-recommended)
and [PMG](MAIL_GATEWAY.md) token guidance. PBS password-based onboarding can
also try to create a token and ACLs on the PBS server; it is not read-only.
Prepare the request in a private editor, not a credential-bearing shell
assignment, here-document or command argument. Use the same Bash session as
the [private-response helper](#api-token-recommended):
```bash
node_request_dir=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/pulse-node.XXXXXX") &&
node_request_file="$node_request_dir/node.json" &&
(umask 077; set -C; : > "$node_request_file") &&
vi "$node_request_file"
```
If preparation or the editor fails, stop. In that editor, enter the following
JSON, replacing the example hostname, connection name, full platform token ID
(`user@realm!token`) and token-secret placeholder with your intended values:
```json
{
"type": "pve",
"name": "Proxmox 1",
"host": "https://198.51.100.10:8006",
"user": "root@pam",
"password": "password"
"name": "PVE connection A",
"host": "https://pve.example.net:8006",
"tokenName": "pulse-monitor@pve!pulse-readonly",
"tokenValue": "<platform-token-secret>",
"verifySSL": true
}
```
### Test Connection
`POST /api/config/nodes/test-connection`
Validate credentials before saving.
Set `verifySSL` explicitly to `true`: omission on these new-connection and
test request paths does **not** enable certificate verification. The example
requires a certificate trusted by the Pulse server for that hostname. For a
self-signed/private certificate, also supply `fingerprint` with the SHA-256
certificate fingerprint verified through an independent trusted channel.
A fingerprint merely fetched from the endpoint is not established trust.
Do not clear an existing pin, set `verifySSL` to `false`, or change to HTTP to
work around a certificate failure.
### Test Node Config (Validation Only)
`POST /api/config/nodes/test-config`
Validates node config without saving.
After reviewing this one intended configuration change, send the file:
```bash
pulse_api POST /api/config/nodes < "$node_request_file"
```
The helper retains the response privately and makes one bounded request,
without retries. A lost response can follow a successful save: re-read the
saved inventory before deciding whether another request is needed. HTTP success
does not establish fresh readings for every node, guest, backup or History view.
Keep both request and response files out of reports and shared repositories;
remove the temporary request under your credential policy when no longer needed.
### Test Connection
`POST /api/config/nodes/test-connection` (admin, `settings:write`)
Tests supplied credentials with live platform requests without saving the
connection. Use the same private JSON-file transport and explicit TLS settings,
only when a live connection test is appropriate; it is not a required precursor
to filing a report. Success covers the reads performed by that test, not all
collection permissions, guest recovery or sustained monitoring.
### Test Node Config (No Save, Live Request)
`POST /api/config/nodes/test-config` (admin, `settings:write`)
This also contacts the platform using the supplied credentials; **no save**
does not mean offline schema validation. Do not run either test merely to
collect new evidence during a freeze/thaw, backup or unresponsive-host incident.
Use existing observations and the [safe diagnostic guidance](TROUBLESHOOTING.md#collect-diagnostics-safely).
### Update Node
`PUT /api/config/nodes/{id}`
`PUT /api/config/nodes/{id}` (admin, `settings:write`)
Review the current connection and submit only the intended supported changes
from a private JSON file. Do not copy masked credentials from a GET response
back as new secrets. Credential rotation and trust changes are separate
decisions; preserve the existing credential and fingerprint when not changing
them. A response failure does not prove the update was unapplied.
### Delete Node
`DELETE /api/config/nodes/{id}`
`DELETE /api/config/nodes/{id}` (admin, `settings:write`)
Removes the Pulse connection, not the platform workloads or the upstream token.
Collection and related alert coverage can stop. Check the exact connection and
arrange any needed independent coverage before an intentional removal; do not
delete and re-add a connection to diagnose missing readings or identity mixing.
### Test Node (Legacy)
`POST /api/config/nodes/{id}/test`
`POST /api/config/nodes/{id}/test` (admin, `settings:write`)
Uses the saved connection for a live test. It is not a passive read of its
last result or a guarantee that ordinary collection recovered.
### Refresh Cluster Nodes
`POST /api/config/nodes/{id}/refresh-cluster`
`POST /api/config/nodes/{id}/refresh-cluster` (admin, `settings:write`)
Actively rediscovers a saved PVE connection's cluster membership. It does not
select authorised nodes, grant missing permissions or repair an unrelated
standalone installation. Keep intentional token restrictions intact.
### Export Configuration
`POST /api/config/export` (instance admin for the default organization, tenant
+94 -15
View File
@@ -936,32 +936,111 @@ an agent assignment remain available in every edition.
### ICMP probe privileges
ICMP probes run the system `ping` binary, which needs the `CAP_NET_RAW`
capability. The systemd unit written by the installer hardens the service
with `NoNewPrivileges=true`, which strips ping's setuid bit and file
capabilities, so the unit also grants the capability directly with
`AmbientCapabilities=CAP_NET_RAW`. Units written by older versions of the
installer lack that line, and ICMP probes fail with
`icmp probe failed: ping: socktype: SOCK_RAW ... missing cap_net_raw+p capability`.
ICMP checks execute the system `ping` binary **on the selected observation
host**. A local permission error (`Operation not permitted`, `cap_net_raw` or
`icmp probe blocked`) is not evidence that the target is down, nor proof that
one particular service setting caused it. First distinguish **This Pulse
server** from an external agent observation. Changing the server's unit cannot
repair an agent's permissions on another host.
To fix an existing install, either re-run the install script (it rewrites
the unit) or add the capability as an override:
For Linux systemd servers, the current installer grants both
`AmbientCapabilities=CAP_NET_RAW` and `CapabilityBoundingSet=CAP_NET_RAW`, while
keeping `NoNewPrivileges=true`. This allows the child `ping` to inherit the
capability without gaining it through setuid or file capabilities. Older units
can lack the ambient grant. **Updates preserve an existing service unit**;
rerunning an installer is not a reliable unit repair, even if an older error
message recommends it. Do not reinstall over a working data directory.
#### Inspect the actual systemd server first
On the machine running the Pulse server, read only these effective properties.
For a Pulse LXC, run this **inside the Pulse LXC**, not on the Proxmox host or a
monitored guest. The example uses `pulse.service`; substitute your actual server
unit throughout, such as `pulse-backend.service` on some older installs,
**not pulse-agent.service**:
```bash
systemctl edit pulse # pulse-backend on ProxmoxVE community-script installs
timeout --signal=TERM --kill-after=1s 8s systemctl show pulse.service \
--property=LoadState,ActiveState,User,NoNewPrivileges,AmbientCapabilities,CapabilityBoundingSet
```
Interpret the result only after a successful read with `LoadState=loaded`.
A missing unit, failed or timed-out read is **unknown**, not an empty capability
set or a stopped server. This command does not print environment values,
credentials or full unit contents; keep those private.
| Existing observation | What follows |
| --- | --- |
| `NoNewPrivileges=yes`, ceiling includes `cap_net_raw`, ambient grant missing | An older non-root server unit may need the narrow override below. |
| Both capability properties already include `cap_net_raw` | Do not add the grant again. Check the selected observation host, installed ping and its OS/container restrictions through normal administration. |
| Ceiling excludes `cap_net_raw`, or the deployment deliberately forbids it | An ambient line alone cannot overcome the ceiling. Preserve that restriction; do not replace the capability set with a permissive one. |
| `User=` is empty/root, or the service is custom | This is not the standard non-root server recipe. Review its existing privilege and data-access requirements using [manual-service guidance](INSTALL.md#2-bare-metal--systemd). Do not change the user or grant broader privileges just to clear the error. |
#### Narrow override for an older non-root server
Use this only for an **active** non-root server whose capability ceiling already
permits `cap_net_raw`, whose ambient grant is missing, and where granting it is
consistent with the deployment's intended policy. Keep deliberately inactive
services inactive. Record the existing drop-in locally before editing; it may
contain private configuration. Preserve every unrelated setting:
```bash
sudo systemctl edit pulse.service
```
Add just this service setting to the drop-in:
```ini
[Service]
AmbientCapabilities=CAP_NET_RAW
```
Then `systemctl daemon-reload && systemctl restart pulse`.
Keep `NoNewPrivileges` enabled; do not run Pulse as root, set file capabilities
on the system-wide ping binary or remove sandbox settings. Do not reset or widen
`CapabilityBoundingSet` to make this example apply to a different profile.
Docker installs are unaffected: Docker's default capability set includes
`NET_RAW`. If you run the container with `--cap-drop=ALL`, add
`--cap-add=NET_RAW` to keep ICMP probes working. TCP and HTTP/HTTPS probes
need no special privileges.
```bash
sudo systemctl daemon-reload
```
If editing or reloading fails, stop. Repeat the read-only property check above
to confirm the intended ambient grant and preserved ceiling/hardening. Reload
changes systemd's configuration, **not the running process's capabilities**.
Schedule the restart as maintenance, outside backups or other guest operations.
**Pulse monitoring and alerts are unavailable during the restart**; arrange
independent outage coverage. Only after the checks pass, for the server that
was active beforehand:
```bash
sudo systemctl restart pulse.service && \
timeout --signal=TERM --kill-after=1s 8s systemctl is-active pulse.service
```
`active` proves service startup, not ICMP recovery or notification delivery.
Check the next normally scheduled observation from the **same location**;
do not induce a workload failure or another notification to prove recovery.
A failed/unknown restart needs normal recovery, not repeated restarts. To undo
this change, edit only the added line, preserve other drop-in content and
reload; do not use `systemctl revert`, which can remove unrelated overrides.
Apply any further restart only under the same maintenance and prior-active
conditions. Preserve the data directory and encryption key throughout.
#### Containers and external agents
Docker's default capability set includes `NET_RAW`, but that does not prove ICMP
works in every rootless, non-root or restricted container. For an intentionally
restricted deployment, review only the selected observation host's ping/socket
permissions and saved deployment. Do not use `--privileged`, `--cap-add=ALL`,
disable user namespaces or recreate a container as a diagnostic. A deliberate
capability change must use the deployment's normal maintenance route while
preserving data and configuration; the systemd override above does not apply.
External agents have their own OS/service requirements; see
[Agent Security](AGENT_SECURITY.md). Do not grant command execution or widen
agent privileges merely because the server's ICMP check failed. TCP and
HTTP/HTTPS checks need no raw-socket capability, but measure different behaviour;
switching protocol or observation location is not proof that ICMP recovered.
---
@@ -979,6 +979,50 @@ private, and report evidence as unavailable if safe collection is not possible.
QNAP and Unraid log locations are described in the
[space troubleshooting section](#installer-fails-with-not-enough-free-disk-space).
### Agent lookup returns HTTP 401 or 403
At `/api/agents/agent/lookup`, Pulse authenticates the request **before**
searching for the host. **HTTP 401** is an authentication rejection;
**HTTP 403** with `missing_scope` means the authenticated token lacks
`agent:report`. An authenticated lookup can return **HTTP 404** when no matching
host exists. A 401 alone does not identify a mistyped, expired or revoked token,
the wrong Pulse instance, or an upstream authentication layer.
The silent token prompt only keeps the secret out of the copied command and
shell history; it does not change reporting permissions. The normal
**Settings → Infrastructure → Install on a host** flow issues a monitoring
token. It does not use `--enroll`; the separate cluster deployment flow exchanges
a bootstrap token for a runtime token. Do not substitute a bootstrap token or
assume an enrolment-specific correction applies to a normal host installation.
Use the [bounded agent log reader](#collect-agent-logs-safely) on the affected
agent host, or inspect an already retained excerpt privately. Distinguish:
- `remote_config_client` reporting a lookup failure: this concerns configuration
retrieval and does not establish whether ordinary host reports were accepted.
- **Pulse rejected this agent's API token** from host reporting: keep its HTTP
status and redacted error, separately from the lookup failure.
A running local service or successful-looking installer output does not prove
that Pulse accepted a fresh report. No host-report error in the selected log
window is inconclusive, not proof of successful reporting. Do not restart,
re-enrol, delete identity or enable debug logging to manufacture that evidence.
In your signed-in administrator session on the **same Pulse instance** the
agent targets, check the intended record under **Settings → Security → API
Tokens** without revealing or copying its secret. Its presence, expiry and
reporting scope help distinguish server-side rejection, but do not prove that
the running service is using that record. Keep service environments, token
files and connection files private; do not paste a credential into a command,
URL or issue. Do not grant administrator scope, change Proxmox permissions or
disable authentication/TLS to test a guess.
If reporting this problem, say whether the existing error is lookup-only or
also affects host reporting, and share only the failure stage, HTTP status and
manually redacted error line. Keep the installation and saved credentials
unchanged until the failing stage is understood; a 401 is not itself a reason
to reinstall or rotate every token.
### pfSense service disabled after a major upgrade
Pulse installs two service files on pfSense: the FreeBSD rc.d service at
@@ -0,0 +1,227 @@
import { createSignal } from 'solid-js';
import { cleanup, fireEvent, render, renderHook, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationsAPI, type Webhook } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { useAlertWebhookDestinationsState } from '@/features/alerts/useAlertWebhookDestinationsState';
import { WebhookConfig } from './WebhookConfig';
vi.mock('@/api/notifications', () => ({
NotificationsAPI: {
getWebhookTemplates: vi.fn(),
createWebhook: vi.fn(),
updateWebhook: vi.fn(),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: { success: vi.fn(), error: vi.fn() },
}));
vi.mock('@/utils/logger', () => ({ logger: { error: vi.fn() } }));
const savedWebhook: Webhook = {
id: 'saved-hook',
name: 'Existing destination',
url: 'https://example.test/receiver',
method: 'POST',
enabled: true,
service: 'generic',
headers: { Authorization: '********' },
customFields: { team: 'ops' },
template: '{"message":"{{message}}"}',
mention: 'operators',
tagFilter: ['department-a'],
tagFilterMode: 'any',
minimumSeverity: 'warning',
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function mount(initial: Webhook[] = []) {
const { result: owner } = renderHook(() => {
const [webhooks, setWebhooks] = createSignal(initial);
return useAlertWebhookDestinationsState({ webhooks, setWebhooks, autoLoad: false });
});
render(() => (
<WebhookConfig
webhooks={owner.webhooks()}
onAdd={owner.addWebhook}
onUpdate={owner.updateWebhook}
onDelete={owner.deleteWebhook}
onTest={owner.testWebhook}
/>
));
return owner;
}
function openNewDraft() {
fireEvent.click(screen.getByRole('button', { name: '+ Add Webhook' }));
fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'New destination' } });
fireEvent.input(screen.getByLabelText('Webhook URL'), {
target: { value: 'https://example.test/new' },
});
}
describe('webhook persistence acknowledgement', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(NotificationsAPI.getWebhookTemplates).mockResolvedValue([]);
});
afterEach(cleanup);
it('keeps the draft and locks conflicting controls until a single create is acknowledged', async () => {
const pending = deferred<Webhook>();
vi.mocked(NotificationsAPI.createWebhook).mockReturnValue(pending.promise);
const owner = mount([savedWebhook]);
openNewDraft();
const save = screen.getByRole('button', { name: 'Add Webhook' });
fireEvent.click(save);
fireEvent.click(save);
expect(NotificationsAPI.createWebhook).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Name')).toHaveValue('New destination');
expect(screen.getByLabelText('Name')).toBeDisabled();
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
for (const name of ['Cancel', 'Edit', 'Delete', 'Disable All', 'Enabled']) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(owner.webhooks()).toEqual([savedWebhook]);
pending.resolve({ ...savedWebhook, id: 'new-hook', name: 'New destination' });
await waitFor(() => expect(screen.queryByLabelText('Name')).not.toBeInTheDocument());
expect(owner.webhooks().map((hook) => hook.id)).toEqual(['saved-hook', 'new-hook']);
expect(notificationStore.success).toHaveBeenCalledTimes(1);
});
it('retains a failed create draft and requires an explicit retry before clearing it', async () => {
vi.mocked(NotificationsAPI.createWebhook).mockRejectedValueOnce(new Error('Save unavailable'));
const owner = mount();
openNewDraft();
fireEvent.input(screen.getByLabelText('Custom header 1 value'), {
target: { value: 'application/custom+json' },
});
fireEvent.click(screen.getByRole('button', { name: 'Add Webhook' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not confirm'));
expect(screen.getByLabelText('Name')).toHaveValue('New destination');
expect(screen.getByLabelText('Webhook URL')).toHaveValue('https://example.test/new');
expect(screen.getByLabelText('Custom header 1 value')).toHaveValue('application/custom+json');
expect(screen.getByLabelText('Name')).toBeEnabled();
expect(owner.webhooks()).toEqual([]);
expect(NotificationsAPI.createWebhook).toHaveBeenCalledTimes(1);
expect(notificationStore.success).not.toHaveBeenCalled();
const retry = deferred<Webhook>();
vi.mocked(NotificationsAPI.createWebhook).mockReturnValueOnce(retry.promise);
fireEvent.click(screen.getByRole('button', { name: 'Add Webhook' }));
// Starting a deliberate retry clears the old warning, not the draft.
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByLabelText('Name')).toHaveValue('New destination');
expect(screen.getByLabelText('Name')).toBeDisabled();
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
expect(owner.webhooks()).toEqual([]);
expect(notificationStore.success).not.toHaveBeenCalled();
expect(NotificationsAPI.createWebhook).toHaveBeenCalledTimes(2);
retry.resolve({ ...savedWebhook, id: 'new-hook', name: 'Accepted destination' });
await waitFor(() => expect(screen.queryByLabelText('Name')).not.toBeInTheDocument());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(notificationStore.success).toHaveBeenCalledTimes(1);
expect(vi.mocked(NotificationsAPI.createWebhook).mock.calls[1][0]).toEqual(
vi.mocked(NotificationsAPI.createWebhook).mock.calls[0][0],
);
expect(owner.webhooks()[0].name).toBe('Accepted destination');
});
it('retains masked credentials, routing and payload edits on update failure, then accepts the response', async () => {
const pending = deferred<Webhook>();
vi.mocked(NotificationsAPI.updateWebhook).mockReturnValueOnce(pending.promise);
const owner = mount([savedWebhook]);
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Edited destination' } });
const draft = screen.getByLabelText('Name');
fireEvent.click(screen.getByRole('button', { name: 'Update Webhook' }));
pending.reject(new Error('Save unavailable'));
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toBe(draft);
expect(draft).toHaveValue('Edited destination');
expect(screen.getByLabelText('Custom header 1 value')).toHaveValue('********');
expect(owner.webhooks()).toEqual([savedWebhook]);
const [id, payload] = vi.mocked(NotificationsAPI.updateWebhook).mock.calls[0];
expect(id).toBe(savedWebhook.id);
expect(payload).toMatchObject({
...savedWebhook,
name: 'Edited destination',
});
vi.mocked(NotificationsAPI.updateWebhook).mockResolvedValueOnce({
...savedWebhook,
name: 'Accepted edit',
enabled: false,
});
fireEvent.click(screen.getByRole('button', { name: 'Update Webhook' }));
await waitFor(() => expect(screen.queryByLabelText('Name')).not.toBeInTheDocument());
expect(owner.webhooks()[0]).toEqual({ ...savedWebhook, name: 'Accepted edit', enabled: false });
expect(vi.mocked(NotificationsAPI.updateWebhook).mock.calls[1]).toEqual([id, payload]);
});
it('allows cancellation after a failure without an automatic save or stale error on reopening', async () => {
vi.mocked(NotificationsAPI.createWebhook).mockRejectedValue(new Error('Save unavailable'));
mount();
openNewDraft();
fireEvent.click(screen.getByRole('button', { name: 'Add Webhook' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: '+ Add Webhook' }));
expect(screen.getByLabelText('Name')).toHaveValue('');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(NotificationsAPI.createWebhook).toHaveBeenCalledTimes(1);
});
it('handles a rejected callback with fixed guidance, without putting its text in the editor', async () => {
const privateDetail = 'synthetic receiver-controlled detail';
render(() => (
<WebhookConfig
webhooks={[]}
onAdd={async () => {
throw new Error(privateDetail);
}}
onUpdate={async () => false}
onDelete={() => {}}
onTest={() => {}}
/>
));
openNewDraft();
fireEvent.click(screen.getByRole('button', { name: 'Add Webhook' }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent('Check the configured destinations'),
);
expect(screen.getByRole('alert')).not.toHaveTextContent(privateDetail);
expect(screen.getByLabelText('Name')).toHaveValue('New destination');
});
it('returns true only after an accepted create or update and false on failure', async () => {
const { result: owner } = renderHook(() =>
useAlertWebhookDestinationsState({ autoLoad: false }),
);
vi.mocked(NotificationsAPI.createWebhook).mockResolvedValue(savedWebhook);
expect(await owner.addWebhook(savedWebhook)).toBe(true);
vi.mocked(NotificationsAPI.updateWebhook).mockRejectedValue(new Error('Save unavailable'));
expect(await owner.updateWebhook(savedWebhook)).toBe(false);
expect(owner.webhooks()).toEqual([savedWebhook]);
vi.mocked(NotificationsAPI.updateWebhook).mockResolvedValue({
...savedWebhook,
enabled: false,
});
expect(await owner.updateWebhook(savedWebhook)).toBe(true);
});
});
@@ -110,8 +110,8 @@ describe('WebhookConfig', () => {
let onTestMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
beforeEach(() => {
onAddMock = vi.fn();
onUpdateMock = vi.fn();
onAddMock = vi.fn().mockResolvedValue(true);
onUpdateMock = vi.fn().mockResolvedValue(true);
onDeleteMock = vi.fn();
onTestMock = vi.fn();
getWebhookTemplatesMock.mockReset();
@@ -630,7 +630,7 @@ describe('WebhookConfig', () => {
expect(onAddMock).not.toHaveBeenCalled();
});
it('resets form and closes panel after adding a webhook', () => {
it('resets form and closes panel after adding a webhook', async () => {
render(() => (
<WebhookConfig
webhooks={[]}
@@ -651,8 +651,8 @@ describe('WebhookConfig', () => {
fireEvent.click(screen.getByText('Add Webhook'));
// Form should close, "+ Add Webhook" should reappear
expect(screen.getByText('+ Add Webhook')).toBeInTheDocument();
// Form closes only after the owner acknowledges the save.
await waitFor(() => expect(screen.getByText('+ Add Webhook')).toBeInTheDocument());
expect(screen.queryByText('Service Type')).not.toBeInTheDocument();
});
@@ -9,8 +9,8 @@ import { useWebhookConfigState } from './useWebhookConfigState';
export interface WebhookConfigProps {
webhooks: Webhook[];
onAdd: (webhook: Omit<Webhook, 'id'>) => void;
onUpdate: (webhook: Webhook) => void;
onAdd: (webhook: Omit<Webhook, 'id'>) => boolean | Promise<boolean>;
onUpdate: (webhook: Webhook) => boolean | Promise<boolean>;
onDelete: (id: string) => void;
onTest: (id: string, webhookData?: Omit<Webhook, 'id'>) => void;
testing?: string | null;
@@ -26,6 +26,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
webhooks={props.webhooks}
templates={state.templates}
testing={props.testing}
saving={state.saving()}
allEnabled={state.allEnabled}
someEnabled={state.someEnabled}
toggleAllWebhooks={state.toggleAllWebhooks}
@@ -58,12 +59,15 @@ export function WebhookConfig(props: WebhookConfigProps) {
testWebhookForm={state.testWebhookForm}
saveWebhook={state.saveWebhook}
testing={props.testing}
saving={state.saving}
saveError={state.saveError}
/>
</Show>
<Show when={!state.adding()}>
<button
onClick={state.openAddForm}
disabled={state.saving()}
class="min-h-11 w-full border border-dashed border-border px-2 py-1 text-xs text-muted hover:bg-surface-hover sm:min-h-0"
>
+ {ALERT_WEBHOOK_ADD_LABEL}
@@ -73,11 +73,18 @@ interface WebhookConfigFormProps {
testWebhookForm: () => void;
saveWebhook: () => void;
testing?: string | null;
saving: Accessor<boolean>;
saveError: Accessor<string | null>;
}
export function WebhookConfigForm(props: WebhookConfigFormProps) {
return (
<div class="space-y-4 text-sm">
<fieldset
class="min-w-0 space-y-4 border-0 p-0 text-sm"
disabled={props.saving()}
aria-busy={props.saving()}
>
<legend class="sr-only">Webhook configuration</legend>
<div>
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-base-content">Service Type</span>
@@ -408,6 +415,12 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
</label>
</div>
<Show when={props.saveError()}>
<p role="alert" class="text-sm text-red-700 dark:text-red-300">
{props.saveError()}
</p>
</Show>
<div class="flex justify-end gap-2 text-xs">
<button
onClick={props.cancelForm}
@@ -429,12 +442,12 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
</Show>
<button
onClick={props.saveWebhook}
disabled={!props.formData().name || !props.formData().url}
disabled={props.saving() || !props.formData().name || !props.formData().url}
class="min-h-11 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed sm:min-h-0"
>
{getAlertWebhookSubmitLabel(Boolean(props.editingId()))}
{props.saving() ? 'Saving…' : getAlertWebhookSubmitLabel(Boolean(props.editingId()))}
</button>
</div>
</div>
</fieldset>
);
}
@@ -17,6 +17,7 @@ interface WebhookConfigListProps {
webhooks: Webhook[];
templates: Accessor<WebhookTemplate[]>;
testing?: string | null;
saving: boolean;
allEnabled: Accessor<boolean>;
someEnabled: Accessor<boolean>;
toggleAllWebhooks: (enabled: boolean) => void;
@@ -39,14 +40,14 @@ export function WebhookConfigList(props: WebhookConfigListProps) {
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => props.toggleAllWebhooks(false)}
disabled={!props.someEnabled()}
disabled={props.saving || !props.someEnabled()}
class="min-h-11 w-full rounded-sm border px-3 py-1 text-xs transition-colors hover:bg-surface-hover sm:min-h-0 sm:w-auto"
>
{getAlertWebhookToggleAllLabel(false)}
</button>
<button
onClick={() => props.toggleAllWebhooks(true)}
disabled={props.allEnabled()}
disabled={props.saving || props.allEnabled()}
class="min-h-11 w-full rounded-sm border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 sm:min-h-0 sm:w-auto dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/25"
>
{getAlertWebhookToggleAllLabel(true)}
@@ -61,6 +62,7 @@ export function WebhookConfigList(props: WebhookConfigListProps) {
<span class="font-medium text-base-content">{webhook.name}</span>
<button
onClick={() => props.onToggleWebhook(webhook)}
disabled={props.saving}
class={`min-h-11 rounded-sm border px-3 py-1 text-xs font-medium transition-colors sm:min-h-0 ${webhook.enabled ? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/25' : 'border-border text-muted hover:bg-surface-hover'}`}
>
{getAlertWebhookToggleLabel(webhook.enabled)}
@@ -100,19 +102,21 @@ export function WebhookConfigList(props: WebhookConfigListProps) {
<div class="mt-3 flex flex-wrap gap-2 border-t border-border-subtle pt-2 sm:justify-end w-full">
<button
onClick={() => props.onTestWebhook(webhook)}
disabled={props.testing === webhook.id || !webhook.enabled}
disabled={props.saving || props.testing === webhook.id || !webhook.enabled}
class="min-h-11 rounded-sm border px-3 py-1 text-xs text-base-content transition-colors hover:bg-surface-hover disabled:opacity-50 sm:min-h-0"
>
{getAlertWebhookTestLabel(props.testing === webhook.id)}
</button>
<button
onClick={() => props.onEditWebhook(webhook)}
disabled={props.saving}
class="min-h-11 rounded-sm border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 sm:min-h-0 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/25"
>
{ALERT_WEBHOOK_EDIT_LABEL}
</button>
<button
onClick={() => props.onDeleteWebhook(webhook)}
disabled={props.saving}
class="min-h-11 rounded-sm border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 sm:min-h-0 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900/25"
>
{ALERT_WEBHOOK_DELETE_LABEL}
@@ -72,6 +72,8 @@ const createCustomFieldInput = (
export interface WebhookConfigState {
adding: Accessor<boolean>;
saving: Accessor<boolean>;
saveError: Accessor<string | null>;
editingId: Accessor<string | null>;
formData: Accessor<WebhookConfigFormData>;
templates: Accessor<WebhookTemplate[]>;
@@ -87,7 +89,7 @@ export interface WebhookConfigState {
cancelForm: () => void;
editWebhook: (webhook: Webhook) => void;
selectService: (service: string) => void;
saveWebhook: () => void;
saveWebhook: () => Promise<void>;
testWebhookForm: () => void;
toggleAllWebhooks: (enabled: boolean) => void;
updateHeaderInput: (index: number, patch: Partial<HeaderInput>) => void;
@@ -100,6 +102,8 @@ export interface WebhookConfigState {
export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigState {
const [adding, setAdding] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [saveError, setSaveError] = createSignal<string | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<WebhookConfigFormData>(createDefaultFormData());
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
@@ -127,6 +131,7 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
};
const resetForm = () => {
setSaveError(null);
setAdding(false);
setEditingId(null);
setFormData(createDefaultFormData());
@@ -157,6 +162,8 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
const someEnabled = () => props.webhooks.some((webhook) => webhook.enabled);
const openAddForm = () => {
if (saving()) return;
setSaveError(null);
setAdding(true);
setEditingId(null);
setFormData(createDefaultFormData());
@@ -168,10 +175,13 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
};
const cancelForm = () => {
if (saving()) return;
resetForm();
};
const editWebhook = (webhook: Webhook) => {
if (saving()) return;
setSaveError(null);
if (webhook.id) {
setEditingId(webhook.id);
}
@@ -242,7 +252,8 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
setShowServiceDropdown(false);
};
const saveWebhook = () => {
const saveWebhook = async () => {
if (saving()) return;
const data = formData();
if (!data.name || !data.url) return;
@@ -250,38 +261,49 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
const customFields = buildMapFromInputs(customFieldInputs());
const normalizedCustomFields = normalizeAlertWebhookCustomFields(data.service, customFields);
if (editingId()) {
props.onUpdate({
...data,
id: editingId()!,
headers,
service: data.service,
template: data.payloadTemplate,
customFields: normalizedCustomFields,
mention: data.mention,
});
resetForm();
return;
setSaving(true);
setSaveError(null);
// The owner updates inventory only after an acknowledged API write. Do
// not discard this draft (including masked saved fields) on an unconfirmed
// result, and never retry a potentially accepted create automatically.
const failureMessage =
'Could not confirm the save. Your changes are still here. Check the configured destinations in another tab before trying again.';
try {
const accepted = editingId()
? await props.onUpdate({
...data,
id: editingId()!,
headers,
service: data.service,
template: data.payloadTemplate,
customFields: normalizedCustomFields,
mention: data.mention,
})
: await props.onAdd({
name: data.name,
url: data.url,
method: data.method,
headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate,
customFields: normalizedCustomFields,
mention: data.mention,
tagFilter: data.tagFilter ?? [],
tagFilterMode: data.tagFilterMode === 'any' ? 'any' : 'all',
minimumSeverity:
data.minimumSeverity === 'critical' || data.minimumSeverity === 'warning'
? data.minimumSeverity
: 'all',
});
if (accepted === true) resetForm();
else setSaveError(failureMessage);
} catch {
// Callback/provider text can contain submitted destination details.
setSaveError(failureMessage);
} finally {
setSaving(false);
}
props.onAdd({
name: data.name,
url: data.url,
method: data.method,
headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate,
customFields: normalizedCustomFields,
mention: data.mention,
tagFilter: data.tagFilter ?? [],
tagFilterMode: data.tagFilterMode === 'any' ? 'any' : 'all',
minimumSeverity:
data.minimumSeverity === 'critical' || data.minimumSeverity === 'warning'
? data.minimumSeverity
: 'all',
});
resetForm();
};
const testWebhookForm = () => {
@@ -336,6 +358,8 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
return {
adding,
saving,
saveError,
editingId,
formData,
templates,
@@ -67,10 +67,7 @@ export function AlertEmailDestinationsSection(props: AlertEmailDestinationsSecti
/>
}
>
<div
ref={settingsRegion}
class={`${!props.config.enabled ? 'pointer-events-none opacity-50 transition-opacity' : 'transition-opacity'}`}
>
<div ref={settingsRegion}>
<EmailProviderSelect
config={props.config}
onChange={(config) => {
@@ -1,4 +1,4 @@
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { WebhookConfig, type WebhookConfigProps } from '@/components/Alerts/WebhookConfig';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import type { Webhook } from '@/api/notifications';
import {
@@ -8,8 +8,8 @@ import {
interface AlertWebhookDestinationsSectionProps {
webhooks: Webhook[];
addWebhook: (webhook: Omit<Webhook, 'id'>) => void;
updateWebhook: (webhook: Webhook) => void;
addWebhook: WebhookConfigProps['onAdd'];
updateWebhook: WebhookConfigProps['onUpdate'];
deleteWebhook: (id: string) => void;
testWebhook: (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => void;
testingWebhook: string | null;
@@ -83,7 +83,7 @@ describe('buildEmailConfigPayload — branch coverage (batch 2)', () => {
// -----------------------------------------------------------------------
// Exact payload shape — toStrictEqual verifies that UI-only fields
// (replyTo, maxRetries, retryDelay, rateLimit) are absent from the
// (replyTo, maxRetries, retryDelay) are absent from the
// output and that falsy scalar values pass through unchanged.
// -----------------------------------------------------------------------
describe('payload shape', () => {
@@ -117,16 +117,14 @@ describe('buildEmailConfigPayload — branch coverage (batch 2)', () => {
to: ['ops@example.com'],
tls: false,
startTLS: false,
rateLimit: 100,
minimumSeverity: 'all',
});
});
it('does not include rateLimit in the payload even when set in the UI config', () => {
// SUSPECTED SOURCE BUG: UIEmailConfig.rateLimit is required but
// buildEmailConfigPayload never forwards it to the backend payload.
// See GLM_REPORT.md.
it('includes the rate limit already supported by the email API', () => {
const result = buildEmailConfigPayload(makeUIEmailConfig({ rateLimit: 100 }));
expect(result).not.toHaveProperty('rateLimit');
expect(result).toHaveProperty('rateLimit', 100);
});
it('does not include replyTo, maxRetries, or retryDelay in the payload', () => {
@@ -144,7 +144,7 @@ describe('buildEmailConfigPayload — branch coverage (batch 3 / 0712c)', () =>
// the value-level toStrictEqual checks in the sibling suite.
// -----------------------------------------------------------------------
describe('payload contract — exact key set', () => {
it('emits exactly the EmailConfig keys in declaration order and omits replyTo/maxRetries/retryDelay/rateLimit', () => {
it('emits exactly the EmailConfig keys including rateLimit and omits UI-only fields', () => {
const result = buildEmailConfigPayload(
makeUIEmailConfig({
replyTo: 'reply@example.com',
@@ -164,14 +164,13 @@ describe('buildEmailConfigPayload — branch coverage (batch 3 / 0712c)', () =>
'to',
'tls',
'startTLS',
'rateLimit',
'minimumSeverity',
]);
expect(result).not.toHaveProperty('replyTo');
expect(result).not.toHaveProperty('maxRetries');
expect(result).not.toHaveProperty('retryDelay');
// rateLimit exists on EmailConfig (optional) yet is never
// forwarded — see GLM_REPORT.md (suspected source bug).
expect(result).not.toHaveProperty('rateLimit');
expect(result).toHaveProperty('rateLimit', 50);
});
});
@@ -650,6 +650,7 @@ describe('buildEmailConfigPayload — branch coverage additions (0713)', () => {
to: ['alerts@example.com'],
tls: true,
startTLS: true,
rateLimit: 0,
minimumSeverity: 'all',
});
});
@@ -0,0 +1,158 @@
import { cleanup, fireEvent, render, renderHook, screen, waitFor } from '@solidjs/testing-library';
import { Show, createSignal, onMount } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { apiFetchJSON } from '@/utils/apiClient';
import { AlertEmailDestinationsSection } from '../AlertEmailDestinationsSection';
import { useAlertDestinationsState } from '../useAlertDestinationsState';
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn() }));
vi.mock('@/stores/license', () => ({ hasFeature: () => false }));
vi.mock('@/api/alerts', () => ({
AlertsAPI: {
getDeadManConfig: vi.fn().mockResolvedValue({ pingUrl: '', configured: false }),
updateDeadManConfig: vi.fn().mockResolvedValue({ success: true, configured: false }),
},
}));
const savedConfig = {
enabled: true,
provider: '',
server: 'smtp.example.test',
port: 587,
username: 'ops@example.test',
password: '***REDACTED***',
from: 'pulse@example.test',
to: ['alerts@example.test'],
tls: false,
startTLS: true,
rateLimit: 17,
tagFilter: ['production'],
tagFilterMode: 'any',
minimumSeverity: 'warning',
};
function writtenEmailConfig() {
const calls = vi
.mocked(apiFetchJSON)
.mock.calls.filter(
([path, options]) => path === '/api/notifications/email' && options?.method === 'PUT',
);
expect(calls).toHaveLength(1);
return JSON.parse(String(calls[0][1]?.body));
}
describe('email rate-limit persistence', () => {
let loadedRateLimit: number | undefined;
let loadedEnabled: boolean;
beforeEach(() => {
loadedRateLimit = 17;
loadedEnabled = true;
vi.mocked(apiFetchJSON).mockReset();
vi.mocked(apiFetchJSON).mockImplementation(
async <T,>(path: string, options?: Parameters<typeof apiFetchJSON>[1]): Promise<T> => {
if (path === '/api/notifications/email') {
return (
options?.method === 'PUT'
? { success: true }
: { ...savedConfig, rateLimit: loadedRateLimit, enabled: loadedEnabled }
) as T;
}
if (path === '/api/notifications/apprise') return { enabled: false, targets: [] } as T;
if (path === '/api/notifications/webhooks' || path === '/api/notifications/email-providers')
return [] as T;
throw new Error('Unexpected synthetic API call');
},
);
});
afterEach(cleanup);
it('keeps the loaded rate limit when saving an unrelated email edit', async () => {
const { result } = renderHook(() =>
useAlertDestinationsState({ activeTab: () => 'destinations' }),
);
await result.loadDestinations();
result.setEmailConfig({ ...result.emailConfig(), from: 'new@example.test' });
await result.saveDestinations();
expect(writtenEmailConfig()).toEqual({ ...savedConfig, from: 'new@example.test' });
});
it.each([0, 1, 120])(
'preserves an explicit saved rate limit of %i on the wire',
async (limit) => {
loadedRateLimit = limit;
const { result } = renderHook(() =>
useAlertDestinationsState({ activeTab: () => 'destinations' }),
);
await result.loadDestinations();
await result.saveDestinations();
expect(writtenEmailConfig().rateLimit).toBe(limit);
},
);
it('sends the existing default when the saved config has no rate limit', async () => {
loadedRateLimit = undefined;
const { result } = renderHook(() =>
useAlertDestinationsState({ activeTab: () => 'destinations' }),
);
await result.loadDestinations();
await result.saveDestinations();
expect(writtenEmailConfig().rateLimit).toBe(60);
});
it.each([true, false])(
'saves an edited rate limit while email enabled is %s',
async (enabled) => {
loadedEnabled = enabled;
function Fixture() {
const state = useAlertDestinationsState({ activeTab: () => 'destinations' });
const [ready, setReady] = createSignal(false);
const [saved, setSaved] = createSignal(false);
onMount(async () => {
await state.loadDestinations();
setReady(true);
});
return (
<Show when={ready()}>
<AlertEmailDestinationsSection
config={state.emailConfig()}
setConfig={state.setEmailConfig}
setHasUnsavedChanges={() => {}}
onTest={() => {
throw new Error('No notification test is allowed in this fixture');
}}
testing={false}
/>
<button
onClick={async () => {
await state.saveDestinations();
setSaved(true);
}}
>
Save changes
</button>
<Show when={saved()}>Saved</Show>
</Show>
);
}
render(() => <Fixture />);
if (!enabled) {
fireEvent.click(await screen.findByRole('button', { name: 'Show settings' }));
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
}
fireEvent.click(await screen.findByRole('button', { name: 'Show advanced options' }));
const input = screen.getByRole('spinbutton', { name: 'Rate limit' });
expect(input).toHaveValue(17);
fireEvent.input(input, { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument());
expect(writtenEmailConfig()).toEqual({ ...savedConfig, rateLimit: 12, enabled });
},
);
});
@@ -72,6 +72,7 @@ describe('useAlertDestinationsState', () => {
to: ['alerts@example.com'],
tls: true,
startTLS: true,
rateLimit: 17,
minimumSeverity: 'critical',
} as any);
vi.mocked(NotificationsAPI.getAppriseConfig).mockResolvedValue({
@@ -129,6 +130,7 @@ describe('useAlertDestinationsState', () => {
expect(RelayAPI.getConfig).toHaveBeenCalledTimes(1);
expect(result.emailConfig().server).toBe('smtp.example.com');
expect(result.emailConfig().minimumSeverity).toBe('critical');
expect(result.emailConfig().rateLimit).toBe(17);
expect(result.appriseConfig().targetsText).toContain('mailto://ops@example.com');
expect(result.appriseConfig().minimumSeverity).toBe('critical');
expect(result.deadManPingUrl()).toBe('***REDACTED***');
@@ -165,7 +167,7 @@ describe('useAlertDestinationsState', () => {
await result.saveDestinations();
expect(NotificationsAPI.updateEmailConfig).toHaveBeenCalledWith(
expect.objectContaining({ server: 'smtp.internal' }),
expect.objectContaining({ server: 'smtp.internal', rateLimit: 17 }),
);
expect(NotificationsAPI.updateAppriseConfig).toHaveBeenCalledWith(
expect.objectContaining({
@@ -42,6 +42,7 @@ export function buildEmailConfigPayload(config: UIEmailConfig): EmailConfig {
to: config.to.map((entry) => entry.trim()).filter((entry) => entry.length > 0),
tls: config.tls,
startTLS: config.startTLS,
rateLimit: config.rateLimit,
};
if (config.tagFilter !== undefined) {
payload.tagFilter = config.tagFilter;
@@ -81,11 +81,13 @@ export function useAlertWebhookDestinationsState(
const created = await NotificationsAPI.createWebhook(webhook);
setWebhooks((current) => [...current, normalizeWebhook(created)]);
notificationStore.success(getAlertWebhookMutationSuccess('add'));
return true;
} catch (error) {
logger.error('Failed to add webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('add'),
);
return false;
}
};
@@ -96,11 +98,13 @@ export function useAlertWebhookDestinationsState(
current.map((entry) => (entry.id === webhook.id ? normalizeWebhook(updated) : entry)),
);
notificationStore.success(getAlertWebhookMutationSuccess('update'));
return true;
} catch (error) {
logger.error('Failed to update webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('update'),
);
return false;
}
};
+84
View File
@@ -15056,6 +15056,90 @@ func TestContract_ResourceReportMergeReplacesOperatorLink(t *testing.T) {
}
}
// A source-filtered report-merge undoes the links that join the members
// carrying the named source, not every link along the way to it: a Docker
// host linked into an agent linked into a VM leaves the VM and the agent
// merged when only the Docker source is named.
func TestContract_ResourceReportMergeSourceFilterKeepsLinksOfUnreportedMembers(t *testing.T) {
now := time.Now().UTC()
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{
LastUpdate: now,
VMs: []models.VM{{ID: "lab:pve1:101", VMID: 101, Name: "web", Node: "pve1", Instance: "lab", Status: "running", Type: "qemu", LastSeen: now}},
Hosts: []models.Host{{ID: "host-box", Hostname: "box-agent", MachineID: "fedcba9876543210", Status: "online", LastSeen: now}},
DockerHosts: []models.DockerHost{{ID: "docker-box", Hostname: "dock-box", MachineID: "0011223344556677", Status: "online", LastSeen: now}},
}})
list := func() []unifiedresources.Resource {
t.Helper()
rec := httptest.NewRecorder()
h.HandleListResources(rec, httptest.NewRequest(http.MethodGet, "/api/resources", nil))
if rec.Code != http.StatusOK {
t.Fatalf("/api/resources status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode /api/resources: %v", err)
}
return resp.Data
}
var vmID, agentID, dockerID string
for _, resource := range list() {
switch {
case resource.Type == unifiedresources.ResourceTypeVM:
vmID = resource.ID
case resource.Docker != nil:
dockerID = resource.ID
case resource.Agent != nil:
agentID = resource.ID
}
}
if vmID == "" || agentID == "" || dockerID == "" {
t.Fatalf("fixture did not list a separate VM, agent and Docker host: %+v", list())
}
link := func(fromID, targetID string) {
t.Helper()
rec := httptest.NewRecorder()
h.HandleLink(rec, httptest.NewRequest(http.MethodPost, "/api/resources/"+fromID+"/link", strings.NewReader(`{"targetId":"`+targetID+`"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("link status = %d, body=%s", rec.Code, rec.Body.String())
}
}
// Links apply in store order: the agent takes in the Docker host, then the
// VM takes in the agent.
link(agentID, dockerID)
link(vmID, agentID)
if chained := list(); len(chained) != 1 {
t.Fatalf("links listed %d resources, want the chain folded into one: %+v", len(chained), chained)
}
rec := httptest.NewRecorder()
h.HandleReportMerge(rec, httptest.NewRequest(http.MethodPost, "/api/resources/"+vmID+"/report-merge", strings.NewReader(`{"sources":["docker"]}`)))
if rec.Code != http.StatusOK {
t.Fatalf("report-merge status = %d, body=%s", rec.Code, rec.Body.String())
}
store, err := h.getStore("default")
if err != nil {
t.Fatal(err)
}
links, err := store.GetLinks()
if err != nil || len(links) != 1 {
t.Fatalf("report-merge of the Docker source left links %+v (err %v), want only the VM and agent link", links, err)
}
if (links[0].ResourceA != vmID || links[0].ResourceB != agentID) && (links[0].ResourceA != agentID || links[0].ResourceB != vmID) {
t.Fatalf("report-merge kept link %+v, want the VM and agent link", links[0])
}
split := list()
if len(split) != 2 {
t.Fatalf("report-merge listed %d resources, want the VM with its agent and the Docker host apart: %+v", len(split), split)
}
for _, resource := range split {
docker := slices.Contains(resource.Sources, unifiedresources.SourceDocker)
if docker != (len(resource.Sources) == 1) {
t.Fatalf("report-merge listed %s with sources %v, want the Docker host alone and the VM without Docker", resource.ID, resource.Sources)
}
}
}
func TestContract_ResourceListPolicyMetadata(t *testing.T) {
now := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
+9 -9
View File
@@ -1019,17 +1019,17 @@ func (h *QueryService) HandleReportMerge(w http.ResponseWriter, r *http.Request)
reason = "reported_incorrect_merge"
}
// An operator link that brought a reported source in is undone by
// excluding the link's own pair, which replaces the link in the store.
// Identity merges are kept apart by excluding the candidate ID each
// source's record takes when no match is allowed; for a linked source that
// candidate names neither side of the link.
// An operator link that joins a member carrying a reported source to the
// part that stays is undone by excluding the link's own pair, which
// replaces the link in the store. A fold's sources span everything its
// folded side took in, so the registry picks links by member instead
// (ReportedManualLinkFolds). Identity merges are kept apart by excluding
// the candidate ID each source's record takes when no match is allowed;
// for a linked source that candidate names neither side of the link.
type exclusionPair struct{ a, b string }
pairs := make([]exclusionPair, 0, len(linkFolds)+len(sourceTargets))
for _, fold := range linkFolds {
if selected(fold.Sources...) {
pairs = append(pairs, exclusionPair{fold.HolderID, fold.FoldedID})
}
for _, fold := range unified.ReportedManualLinkFolds(path, linkFolds, selected) {
pairs = append(pairs, exclusionPair{fold.HolderID, fold.FoldedID})
}
for _, target := range sourceTargets {
if !selected(target.Source) || target.CandidateID == "" || target.CandidateID == path {
@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -144,6 +146,109 @@ func TestResourceReportMergeSourceFilterSelectsLinks(t *testing.T) {
}
}
// A source filter detaches the members of a chain that carry the source. A
// Docker host linked into an agent that is linked into a VM leaves two folds,
// and the agent's fold lists the Docker source as well, because its subtree
// holds the Docker host. Naming Docker used to undo the VM-agent link too, and
// the pair was apart on every surface; now only the link that holds the Docker
// host goes. Naming the agent detaches the agent from the VM and keeps the
// Docker host in the agent, which the report does not name.
func TestResourceReportMergeSourceFilterDetachesChainMembers(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
LastUpdate: now,
VMs: []models.VM{{ID: "lab:pve1:101", VMID: 101, Name: "web", Node: "pve1", Instance: "lab", Status: "running", Type: "qemu", LastSeen: now}},
Hosts: []models.Host{{ID: "host-box", Hostname: "box-agent", MachineID: "fedcba9876543210", Status: "online", LastSeen: now}},
DockerHosts: []models.DockerHost{{ID: "docker-box", Hostname: "dock-box", MachineID: "0011223344556677", Status: "online", LastSeen: now}},
}
for _, tc := range []struct {
name string
sources []string
// want lists the sources of each resource left standing, and links
// how many of the chain's two links the report leaves in the store.
want []string
links int
}{
{"leaf member", []string{"docker"}, []string{"agent+proxmox", "docker"}, 1},
{"member that took another in", []string{"agent"}, []string{"agent+docker", "proxmox"}, 1},
{"both members", []string{"agent", "docker"}, []string{"agent", "docker", "proxmox"}, 0},
{"every source", []string{"proxmox", "agent", "docker"}, []string{"agent", "docker", "proxmox"}, 0},
{"no filter", nil, []string{"agent", "docker", "proxmox"}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
h, store, rebuild := newReportMergeHarness(t, snapshot)
var vmID, agentID, dockerID string
for _, resource := range rebuild() {
switch {
case resource.Type == unified.ResourceTypeVM:
vmID = resource.ID
case resource.Agent != nil:
agentID = resource.ID
case resource.Docker != nil:
dockerID = resource.ID
}
}
if vmID == "" || agentID == "" || dockerID == "" {
t.Fatalf("fixture lacks a standalone VM, agent and Docker host: %+v", rebuild())
}
// Links apply in store order: the agent takes in the Docker host,
// then the VM takes in the agent.
postResourceAction(t, h, agentID, "link", map[string]any{"targetId": dockerID})
postResourceAction(t, h, vmID, "link", map[string]any{"targetId": agentID})
chained := rebuild()
if got := resourceSourceGroups(chained); !slices.Equal(got, []string{"agent+docker+proxmox"}) {
t.Fatalf("links left %v, want the chain folded into one resource", got)
}
assertRESTSourceGroups(t, h, []string{"agent+docker+proxmox"})
body := map[string]any{}
if tc.sources != nil {
body["sources"] = tc.sources
}
postResourceAction(t, h, chained[0].ID, "report-merge", body)
if got := resourceSourceGroups(rebuild()); !slices.Equal(got, tc.want) {
t.Fatalf("monitor lists %v after reporting %v, want %v", got, tc.sources, tc.want)
}
assertRESTSourceGroups(t, h, tc.want)
if links, err := store.GetLinks(); err != nil || len(links) != tc.links {
t.Fatalf("report-merge left %d links (err %v), want %d: %+v", len(links), err, tc.links, links)
}
})
}
}
// resourceSourceGroups lists the sources of each resource, sorted, as
// "a+b" groups in sorted order.
func resourceSourceGroups(resources []unified.Resource) []string {
groups := make([]string, 0, len(resources))
for _, resource := range resources {
sources := make([]string, 0, len(resource.Sources))
for _, source := range resource.Sources {
sources = append(sources, string(source))
}
slices.Sort(sources)
groups = append(groups, strings.Join(sources, "+"))
}
slices.Sort(groups)
return groups
}
func assertRESTSourceGroups(t *testing.T, h *QueryService, want []string) {
t.Helper()
rec := httptest.NewRecorder()
h.HandleListResources(rec, httptest.NewRequest(http.MethodGet, "/api/resources", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode list: %v", err)
}
if got := resourceSourceGroups(resp.Data); !slices.Equal(got, want) {
t.Fatalf("REST lists %v, want %v", got, want)
}
}
// Report-merge on a pair identity matching merged keeps it apart through the
// monitor's rebuilds, whose identity pins persist on one generation and steer
// the next.
@@ -0,0 +1,155 @@
package availabilityprobe
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestICMPProbeDiagnostics(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("synthetic ping fixture requires a POSIX shell; no real ping is used")
}
runners := []struct {
name string
run func(context.Context, config.AvailabilityTarget) (ProbeResult, error)
}{
{"Run", func(ctx context.Context, target config.AvailabilityTarget) (ProbeResult, error) {
return ProbeResult{}, Run(ctx, target)
}},
{"Result", func(ctx context.Context, target config.AvailabilityTarget) (ProbeResult, error) {
outcome, err := Result(ctx, target)
return ProbeResult{Outcome: outcome}, err
}},
{"DetailedResult", DetailedResult},
}
const privateMarker = "synthetic-private-ping-details"
cases := []struct {
name string
output string
exit int
blocked bool
wantError string
contextErr error
missing bool
}{
{name: "operation-not-permitted", output: "ping: socket: Operation not permitted\n" + privateMarker, exit: 1, blocked: true},
{name: "cap-net-raw", output: "ping: missing cap_net_raw capability\n" + privateMarker, exit: 2, blocked: true},
{name: "ordinary-error", output: " ping: unknown host\n", exit: 1, wantError: "icmp probe failed: ping: unknown host"},
{name: "bounded-output", output: strings.Repeat("x", 300), exit: 1, wantError: "icmp probe failed: " + strings.Repeat("x", 240)},
{name: "empty-output", exit: 17, wantError: "icmp probe failed: exit status 17"},
{name: "success-with-permission-like-output", output: "Operation not permitted cap_net_raw", exit: 0},
{name: "cancelled", contextErr: context.Canceled},
{name: "expired", contextErr: context.DeadlineExceeded},
{name: "missing-ping", missing: true},
}
for _, runner := range runners {
t.Run(runner.name, func(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
argsFile := filepath.Join(dir, "args")
if !tc.missing {
// Shell builtins only: this fixture cannot contact a target or
// accidentally delegate to the host's actual ping binary.
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$PULSE_TEST_PING_ARGS\"\nprintf '%s' \"$PULSE_TEST_PING_OUTPUT\" >&2\nexit \"$PULSE_TEST_PING_EXIT\"\n"
if err := os.WriteFile(filepath.Join(dir, "ping"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", dir)
t.Setenv("PULSE_TEST_PING_ARGS", argsFile)
t.Setenv("PULSE_TEST_PING_OUTPUT", tc.output)
t.Setenv("PULSE_TEST_PING_EXIT", fmt.Sprint(tc.exit))
ctx := context.Background()
if tc.contextErr == context.Canceled {
cancelled, cancel := context.WithCancel(ctx)
cancel()
ctx = cancelled
} else if tc.contextErr == context.DeadlineExceeded {
expired, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Second))
defer cancel()
ctx = expired
}
target := config.AvailabilityTarget{
Address: "192.0.2.10", Protocol: config.AvailabilityProbeICMP, Enabled: true, TimeoutMillis: 1375,
}
result, err := runner.run(ctx, target)
if runner.name == "DetailedResult" && (result.TransportOutcome != result.Outcome || result.Application != nil || result.Certificate != nil) {
t.Errorf("ICMP result changed non-diagnostic fields: %+v", result)
}
switch {
case tc.blocked:
if err == nil {
t.Fatal("permission failure was reported as success")
}
message := err.Error()
for _, required := range []string{"icmp probe blocked", "local permissions", "selected observation host", "server or agent", "docs/CONFIGURATION.md#icmp-probe-privileges", "NoNewPrivileges", "intentional", "restrictions"} {
if !strings.Contains(message, required) {
t.Errorf("permission diagnostic %q omits %q", message, required)
}
}
for _, unsafe := range []string{privateMarker, "Pulse service unit", "installer", "AmbientCapabilities=", "CapabilityBoundingSet=", "restart", "setcap", "--privileged", "--cap-add", "as root"} {
if strings.Contains(message, unsafe) {
t.Errorf("permission diagnostic %q contains unsupported advice or raw details %q", message, unsafe)
}
}
if len(message) > 240 {
t.Errorf("diagnostic has %d bytes; agent reports truncate after 240", len(message))
}
case tc.contextErr != nil:
if !errors.Is(err, tc.contextErr) {
t.Fatalf("error = %v, want %v", err, tc.contextErr)
}
case tc.missing:
if !errors.Is(err, exec.ErrNotFound) || !strings.HasPrefix(err.Error(), "icmp probe failed: ") {
t.Fatalf("error = %v, want wrapped missing executable error", err)
}
case tc.wantError != "":
if err == nil || err.Error() != tc.wantError {
t.Fatalf("error = %v, want %q", err, tc.wantError)
}
if tc.output == "" {
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != tc.exit {
t.Fatalf("error = %v, want wrapped exit %d", err, tc.exit)
}
}
default:
if err != nil {
t.Fatalf("successful ping returned error: %v", err)
}
}
if runner.name != "Run" {
want := OutcomeReachable
if err != nil {
want = OutcomeUnreachable // Existing classification is unchanged.
}
if result.Outcome != want {
t.Fatalf("outcome = %q, want %q", result.Outcome, want)
}
}
args, argsErr := os.ReadFile(argsFile)
if tc.contextErr != nil || tc.missing {
if !errors.Is(argsErr, os.ErrNotExist) {
t.Fatalf("unstarted ping left arguments: %q, error %v", args, argsErr)
}
} else if want := strings.Join(pingArgs(target.Address, target.TimeoutMillis), "\n") + "\n"; argsErr != nil || string(args) != want {
t.Fatalf("ping arguments = %q, error %v; want %q", args, argsErr, want)
}
})
}
})
}
}
+4 -4
View File
@@ -200,11 +200,11 @@ func probeICMP(ctx context.Context, target config.AvailabilityTarget) error {
if details == "" {
return fmt.Errorf("icmp probe failed: %w", err)
}
// Units written before v6.1.0-rc.1 lack AmbientCapabilities=CAP_NET_RAW and
// in-place updates never rewrite the unit, so ping fails like this on every
// upgraded install (#1554). Point at the unit instead of echoing ping stderr.
// The check runs on the selected server or agent, and a permission failure
// does not identify a particular unit setting. Keep the guidance within the
// agent's 240-byte error limit without recommending privilege escalation.
if strings.Contains(details, "Operation not permitted") || strings.Contains(details, "cap_net_raw") {
return fmt.Errorf("icmp probe blocked. The Pulse service unit does not grant CAP_NET_RAW, so ping cannot open a socket. Re-run the Pulse installer to regenerate the unit, or add a systemd override with AmbientCapabilities=CAP_NET_RAW and CapabilityBoundingSet=CAP_NET_RAW, then restart the service")
return fmt.Errorf("icmp probe blocked by local permissions on the selected observation host (server or agent). See docs/CONFIGURATION.md#icmp-probe-privileges. Preserve NoNewPrivileges and intentional capability/container restrictions.")
}
if len(details) > 240 {
details = details[:240]
@@ -0,0 +1,47 @@
package hostagent
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestAvailabilityModuleICMPPermissionDiagnosticSurvivesReport(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("synthetic ping fixture requires a POSIX shell; no real ping is used")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "ping"), []byte("#!/bin/sh\nprintf '%s' 'ping: socket: Operation not permitted' >&2\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
module := testProbeModule(t, nil) // Exercise the shared core, not the test callback.
target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{
ID: "remote-icmp", Address: "192.0.2.10", Protocol: config.AvailabilityProbeICMP, Enabled: true, ConfigRevision: 7,
})
module.check(context.Background(), target)
results := module.snapshotForReport()
if len(results) != 1 {
t.Fatalf("report has %d observations, want one", len(results))
}
result := results[0]
if result.TargetID != target.ID || result.ConfigRevision != target.ConfigRevision || result.ObservationID == "" || result.CheckedAt.IsZero() {
t.Fatalf("report lost observation identity: %+v", result)
}
if result.Outcome != "unreachable" || result.TransportOutcome != "unreachable" {
t.Fatalf("report changed existing ICMP failure classification: %+v", result)
}
for _, required := range []string{"local permissions", "selected observation host", "server or agent", "docs/CONFIGURATION.md#icmp-probe-privileges", "NoNewPrivileges", "intentional capability/container restrictions."} {
if !strings.Contains(result.Error, required) {
t.Errorf("agent diagnostic %q lost %q", result.Error, required)
}
}
if len(result.Error) > availabilityErrorLimit || strings.Contains(result.Error, "Pulse service unit") || strings.Contains(result.Error, "installer") {
t.Fatalf("agent diagnostic is unbounded or blames the server: %q", result.Error)
}
}
+30 -6
View File
@@ -218,8 +218,7 @@ func enableMockMode(config MockConfig, fromInit bool) {
dataMu.Lock()
mockConfig = config
mockGraph = buildFixtureGraph(config, now)
fixtureRevision.Add(1)
fixtureDataVersion.Add(1)
advanceFixtureStructure()
metricCohort.Store(0)
enabled.Store(true)
dataMu.Unlock()
@@ -255,8 +254,7 @@ func disableMockMode() {
dataMu.Lock()
mockGraph = emptyFixtureGraph()
fixtureRevision.Add(1)
fixtureDataVersion.Add(1)
advanceFixtureStructure()
dataMu.Unlock()
log.Info().Msg("mock mode disabled")
@@ -334,6 +332,33 @@ func FixtureDataVersion() uint64 {
return fixtureDataVersion.Load()
}
// FixtureStructureRevision returns a token that advances only when the mock
// estate itself changes (mock mode enabled or disabled, or a configuration
// that rebuilds the graph), never on a metric tick. Which resources exist,
// their identities and their source mappings are fixed between two reads of
// the same revision, so a lookup that depends only on those, such as a
// resource's metrics target, may be served from a view built at any data
// version of that revision.
//
// A cache that tags a snapshot with the revision must read the revision
// BEFORE the data version it memoizes the snapshot under. The two counters
// advance under dataMu, data version first (advanceFixtureStructure), so a
// reader that sees a new revision is guaranteed the new data version after
// it and cannot be handed the previous estate's memoized snapshot under the
// new revision; the other interleaving only tags a newer snapshot with an
// older revision, which costs one rebuild.
func FixtureStructureRevision() uint64 {
return fixtureRevision.Load()
}
// advanceFixtureStructure records a structural change of the fixture graph.
// Callers hold dataMu for writing. The data version moves before the
// structure revision; see FixtureStructureRevision for the ordering contract.
func advanceFixtureStructure() {
fixtureDataVersion.Add(1)
fixtureRevision.Add(1)
}
// GetConfig returns the current mock configuration.
func GetConfig() MockConfig {
dataMu.RLock()
@@ -539,8 +564,7 @@ func SetMockConfig(cfg MockConfig) {
setMockUpdateInterval(normalized.UpdateInterval)
if configChanged && enabled.Load() {
mockGraph = buildFixtureGraph(normalized, time.Now())
fixtureRevision.Add(1)
fixtureDataVersion.Add(1)
advanceFixtureStructure()
}
dataMu.Unlock()
+284
View File
@@ -4,8 +4,15 @@ import (
"bytes"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"math"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -1017,3 +1024,280 @@ func TestFixtureGraphMetricCohortProducesSparseUnifiedResourceChanges(t *testing
t.Fatalf("expected sparse unified resource churn, changed=%d total=%d", changed, len(after))
}
}
func fixtureMetricsTargets(t *testing.T) map[string]unifiedresources.MetricsTarget {
t.Helper()
resources, _ := UnifiedResourceSnapshot()
if len(resources) == 0 {
t.Fatal("expected mock resources")
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestResources(resources)
_, targets := registry.ListWithMetricsTargets()
if len(targets) == 0 {
t.Fatal("expected the mock estate to resolve metrics targets")
}
return targets
}
func fixtureResourceIDs(t *testing.T) []string {
t.Helper()
resources, _ := UnifiedResourceSnapshot()
ids := make([]string, 0, len(resources))
for _, resource := range resources {
ids = append(ids, resource.ID)
}
slices.Sort(ids)
return ids
}
// smallMockEstateForTest replaces the ambient estate with a fixed small one,
// so a test neither depends on the process's PULSE_MOCK_* settings (a node
// count at the supported maximum clamps back when raised) nor pays for a
// 929-guest snapshot per assertion. The ambient configuration returns when
// the test ends.
func smallMockEstateForTest(t *testing.T, nodes int) MockConfig {
t.Helper()
previous := GetConfig()
t.Cleanup(func() { SetMockConfig(previous) })
small := previous
small.NodeCount = nodes
SetMockConfig(small)
return small
}
// Callers cache a resource's metrics target for as long as the structure
// revision holds (see Monitor.MetricsTargetForResource), so a metric tick must
// leave the revision, the resource set and every target exactly as they were.
// Checked after every tick of two full cohort rotations, not only at the end,
// so a change that a later tick reverts is still caught.
func TestMetricTicksNeverChangeTheFixtureStructure(t *testing.T) {
withMockEnabledForTest(t)
stopUpdateLoop()
t.Cleanup(func() {
if IsMockEnabled() {
startUpdateLoop()
}
})
smallMockEstateForTest(t, 6)
stopUpdateLoop()
revision := FixtureStructureRevision()
ids := fixtureResourceIDs(t)
targets := fixtureMetricsTargets(t)
version := FixtureDataVersion()
for tick := 1; tick <= mockMetricCohortCount*2+1; tick++ {
updateMetrics(GetConfig())
if got := FixtureDataVersion(); got <= version {
t.Fatalf("tick %d did not advance the data version (%d)", tick, got)
} else {
version = got
}
if got := FixtureStructureRevision(); got != revision {
t.Fatalf("tick %d moved the fixture structure revision from %d to %d", tick, revision, got)
}
if got := fixtureResourceIDs(t); !slices.Equal(got, ids) {
t.Fatalf("tick %d changed the resource set: %d resources before, %d after", tick, len(ids), len(got))
}
if got := fixtureMetricsTargets(t); !reflect.DeepEqual(got, targets) {
t.Fatalf("tick %d changed the resolved metrics targets", tick)
}
}
}
func TestFixtureStructureRevisionAdvancesOnStructuralChange(t *testing.T) {
withMockEnabledForTest(t)
stopUpdateLoop()
t.Cleanup(func() {
if IsMockEnabled() {
startUpdateLoop()
}
})
small := smallMockEstateForTest(t, 3)
stopUpdateLoop()
revision := FixtureStructureRevision()
grown := small
grown.NodeCount = 4
SetMockConfig(grown)
if got := FixtureStructureRevision(); got <= revision {
t.Fatalf("reconfiguring the estate left the structure revision at %d (was %d)", got, revision)
}
revision = FixtureStructureRevision()
if err := SetEnabled(false); err != nil {
t.Fatalf("disable mock mode: %v", err)
}
if got := FixtureStructureRevision(); got <= revision {
t.Fatalf("disabling mock mode left the structure revision at %d (was %d)", got, revision)
}
revision = FixtureStructureRevision()
if err := SetEnabled(true); err != nil {
t.Fatalf("enable mock mode: %v", err)
}
if got := FixtureStructureRevision(); got <= revision {
t.Fatalf("enabling mock mode left the structure revision at %d (was %d)", got, revision)
}
}
// A cache fill reads the structure revision before the data version, and the
// fixture advances its data version before its revision, so a reader that
// sees a new revision is guaranteed the new data version after it and is
// never handed the previous estate's memoized snapshot under the new
// revision. The interleaving cannot be forced from outside, so the order of
// the two writes and of the two reads is pinned on the syntax tree: inside
// the named functions only, so comments, local names and the position of the
// functions in their package do not matter.
func TestFixtureStructureRevisionOrderingContract(t *testing.T) {
mockDecls := parsePackageFuncs(t, ".")
advance := packageFunc(mockDecls["advanceFixtureStructure"])
if advance == nil {
t.Fatal("the mock package lost advanceFixtureStructure")
}
version := synchronousCallOffsets(advance, "fixtureDataVersion", "Add")
revision := synchronousCallOffsets(advance, "fixtureRevision", "Add")
if len(version) != 1 || len(revision) != 1 || version[0] > revision[0] {
t.Fatal("advanceFixtureStructure must advance the data version once, before the structure revision once, as plain statements")
}
if got := countCalls(advance, "fixtureRevision", "Add") + countCalls(advance, "fixtureDataVersion", "Add"); got != 2 {
t.Fatalf("advanceFixtureStructure must not defer or spawn its counter writes; found %d writes in all", got)
}
// Every declaration counts for the bypass scan, including methods and the
// build-tagged duplicates of a function.
for name, decls := range mockDecls {
if name == "advanceFixtureStructure" {
continue
}
for _, decl := range decls {
if got := countCalls(decl, "fixtureRevision", "Add"); got != 0 {
t.Fatalf("%s advances the structure revision directly; it must go through advanceFixtureStructure", name)
}
}
}
for _, name := range []string{"enableMockMode", "disableMockMode", "SetMockConfig"} {
decl := packageFunc(mockDecls[name])
if decl == nil {
t.Fatalf("the mock package lost %s", name)
}
if got := len(synchronousIdentCallOffsets(decl, "advanceFixtureStructure")); got != 1 {
t.Fatalf("%s must advance the fixture structure through advanceFixtureStructure exactly once, as a plain statement; found %d", name, got)
}
}
view := packageFunc(parsePackageFuncs(t, "../monitoring")["currentUnifiedStateView"])
if view == nil {
t.Fatal("the monitoring package lost currentUnifiedStateView")
}
structureRead := synchronousCallOffsets(view, "mock", "FixtureStructureRevision")
versionRead := synchronousCallOffsets(view, "mock", "FixtureDataVersion")
if len(structureRead) != 1 || len(versionRead) != 1 || structureRead[0] > versionRead[0] {
t.Fatal("currentUnifiedStateView must read the structure revision once, before it reads the data version")
}
}
// parsePackageFuncs returns every non-test function and method declaration of
// the package in dir, keyed by name. Names repeat across build-tagged files
// and between functions and methods, so each key holds all of them.
func parsePackageFuncs(t *testing.T, dir string) map[string][]*ast.FuncDecl {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
funcs := make(map[string][]*ast.FuncDecl)
fset := token.NewFileSet()
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
for _, decl := range file.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok {
funcs[fn.Name.Name] = append(funcs[fn.Name.Name], fn)
}
}
}
return funcs
}
// packageFunc returns the declaration when it is the only one of its name in
// the package (function or method), and nil otherwise.
func packageFunc(decls []*ast.FuncDecl) *ast.FuncDecl {
if len(decls) != 1 {
return nil
}
return decls[0]
}
// walkSynchronous visits the nodes of fn that run in the function's own
// control flow: it does not descend into function literals, deferred calls or
// goroutine starts, whose source position says nothing about when they run.
func walkSynchronous(fn *ast.FuncDecl, visit func(ast.Node)) {
ast.Inspect(fn, func(node ast.Node) bool {
switch node.(type) {
case *ast.FuncLit, *ast.DeferStmt, *ast.GoStmt:
return false
}
visit(node)
return true
})
}
func isSelectorCall(node ast.Node, receiver, method string) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return false
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok || selector.Sel.Name != method {
return false
}
ident, ok := selector.X.(*ast.Ident)
return ok && ident.Name == receiver
}
// synchronousCallOffsets lists the positions of receiver.method calls that run
// in fn's own control flow, in source order.
func synchronousCallOffsets(fn *ast.FuncDecl, receiver, method string) []token.Pos {
var offsets []token.Pos
walkSynchronous(fn, func(node ast.Node) {
if isSelectorCall(node, receiver, method) {
offsets = append(offsets, node.Pos())
}
})
return offsets
}
// synchronousIdentCallOffsets lists the positions of calls to the package-level
// function name that run in fn's own control flow.
func synchronousIdentCallOffsets(fn *ast.FuncDecl, name string) []token.Pos {
var offsets []token.Pos
walkSynchronous(fn, func(node ast.Node) {
if call, ok := node.(*ast.CallExpr); ok {
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == name {
offsets = append(offsets, call.Pos())
}
}
})
return offsets
}
// countCalls counts every receiver.method call anywhere in fn, including
// deferred calls, goroutines and function literals.
func countCalls(fn *ast.FuncDecl, receiver, method string) int {
count := 0
ast.Inspect(fn, func(node ast.Node) bool {
if isSelectorCall(node, receiver, method) {
count++
}
return true
})
return count
}
@@ -0,0 +1,246 @@
package monitoring
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
type incompleteFilesystemClient struct {
mockPVEClientExtended
filesystems []proxmox.VMFileSystem
}
func (c *incompleteFilesystemClient) GetVMFSInfo(context.Context, string, int) ([]proxmox.VMFileSystem, error) {
return c.filesystems, nil
}
func TestVMFilesystemIncompleteAlternateClientCannotPublishRemainder(t *testing.T) {
for name, bad := range map[string]proxmox.VMFileSystem{
"overused": {TotalBytes: 9000, UsedBytes: 9001},
"signed-overflow": {TotalBytes: uint64(math.MaxInt64) + 1, UsedBytes: 0},
} {
t.Run(name, func(t *testing.T) {
bad.Mountpoint, bad.Type, bad.Disk = "/data", "ext4", "/dev/vdb1"
peer := proxmox.VMFileSystem{Mountpoint: "/", Type: "ext4", Disk: "/dev/vda1", TotalBytes: 1000, UsedBytes: 0}
c := &incompleteFilesystemClient{filesystems: []proxmox.VMFileSystem{peer, bad}}
m := &Monitor{}
res := proxmox.ClusterResource{Node: "node", VMID: 105, MaxDisk: 10000}
total, used, free, usage, disks, fresh, reason := m.updateVMDisksFromGuestAgentFSInfo(context.Background(), "partial", res, c, 10000, 0, -1)
if total != 10000 || used != 0 || free != 10000 || usage != -1 || len(disks) != 0 || fresh || reason != "agent-error" {
t.Fatalf("invalid peer became a complete current aggregate: %d/%d/%d usage=%v rows=%+v fresh=%t reason=%q", total, used, free, usage, disks, fresh, reason)
}
// A real subsequent complete inventory may remove the other volume.
c.filesystems = []proxmox.VMFileSystem{peer}
total, used, free, usage, disks, fresh, reason = m.updateVMDisksFromGuestAgentFSInfo(context.Background(), "partial", res, c, 10000, 0, -1)
if total != 1000 || used != 0 || free != 1000 || usage != 0 || len(disks) != 1 || !fresh || reason != "" {
t.Fatal("complete inventory removal/zero was treated as partial")
}
})
}
}
// Ordinary QEMU-only collection must not turn a malformed large volume into
// recovery/removal merely because a smaller valid volume now reports zero.
// Exercise both collectors, canonical previous state, served JSON, History and
// genuine filesystem alerts. No guest freeze, command bypass or native probe.
func TestVMFilesystemIncompletePollingPreservesHistoryAndBreach(t *testing.T) {
for _, path := range []string{"cluster", "node"} {
t.Run(path, func(t *testing.T) {
var phase, fsCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/status/current"):
fmt.Fprint(w, `{"data":{"status":"running","agent":1,"maxmem":1000,"mem":400,"meminfo":{"total":1000,"available":600}}}`)
case strings.HasSuffix(r.URL.Path, "/agent/get-fsinfo"):
fsCalls.Add(1)
rows := `{"mountpoint":"/","type":"ext4","disk":[{"dev":"/dev/vda1"}],"total-bytes":1000,"used-bytes":980},{"mountpoint":"/data","type":"ext4","disk":[{"dev":"/dev/vdb1"}],"total-bytes":9000,"used-bytes":8820}`
switch phase.Load() {
case 1:
rows = `{"mountpoint":"/","type":"ext4","disk":[{"dev":"/dev/vda1"}],"total-bytes":1000,"used-bytes":0},{"mountpoint":"/data","type":"ext4","total-bytes":9000}`
case 2:
rows = `{"mountpoint":"/","type":"ext4","disk":[{"dev":"/dev/vda1"}],"total-bytes":1000,"used-bytes":0}`
}
fmt.Fprintf(w, `{"data":{"result":[%s]}}`, rows)
case strings.Contains(r.URL.Path, "/agent/"):
fmt.Fprint(w, `{"data":{"result":[]}}`)
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
m := guestHistoryObservationMonitor(t)
m.alertManager = alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore())
t.Cleanup(m.alertManager.Stop)
cfg := m.alertManager.GetConfig()
cfg.Enabled = true
cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}
cfg.MetricTimeThresholds = map[string]map[string]int{"guest": {"disk": 0}}
m.alertManager.UpdateConfig(cfg)
m.config, m.state, m.rateTracker = &config.Config{}, models.NewState(), NewRateTracker()
m.guestMetadataLimiter = make(map[string]time.Time)
m.resourceStore = unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 1000, Mem: 400, MaxDisk: 10000, CPU: .2}
id := makeGuestID("partial-filesystems", "node", 105)
publish := func(vm models.VM) {
m.state.UpdateVMs([]models.VM{vm})
m.updateResourceStore(m.currentStateWithScope())
}
build := func() models.VM {
t.Helper()
previous := m.previousGuestContextForInstance("partial-filesystems").vmsByID[id]
var vm models.VM
if path == "node" {
vms, _ := m.pollNodeVMsWithClusterResourceBuilder(context.Background(), "partial-filesystems", "node", []proxmox.VM{{VMID: 105, Name: res.Name, Status: res.Status, MaxMem: res.MaxMem, Mem: res.Mem, MaxDisk: res.MaxDisk, CPU: res.CPU}}, client, map[string]models.VM{id: previous}, nil)
if len(vms) != 1 {
t.Fatal("node collector lost guest")
}
vm = vms[0]
} else {
var ok bool
vm, _, _, _, _, ok = m.buildVMFromClusterResource(context.Background(), "partial-filesystems", res, client, id, nil, &previous)
if !ok {
t.Fatal("cluster builder lost guest")
}
m.checkGuestAlertsForVM("partial-filesystems", vm)
}
publish(vm)
m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second))
view := m.currentModeReadState().VMs()[0]
if view.DiskStatusReason() != vm.DiskStatusReason || view.DiskObservation() != vm.DiskObservation || !reflect.DeepEqual(previousVMFromView(view).Disks, vm.Disks) {
t.Fatalf("canonical projection changed completeness, original age or mount rows: vm=%+v view=%+v", vm, previousVMFromView(view))
}
if vm.Disk.Usage >= 0 && (view.DiskTotal() != vm.Disk.Total || view.DiskUsed() != vm.Disk.Used || view.DiskPercent() != vm.Disk.Usage) {
t.Fatal("canonical projection changed a known disk tuple")
}
// Allocated capacity without usage is not a canonical metric.
// Check absence on the wire below, not equality with raw allocation.
if vm.Disk.Usage < 0 && (view.DiskTotal() != 0 || view.DiskUsed() != 0 || view.DiskPercent() != 0) {
t.Fatal("canonical projection retained expired disk metrics")
}
front := m.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{VMs: []models.VM{vm}}, m.mockModeFence.begin())
wire, err := json.Marshal(front.Resources)
var served []struct {
Proxmox unifiedresources.ProxmoxData
CPU *models.ResourceMetricFrontend
Memory *models.ResourceMetricFrontend
Disk *models.ResourceMetricFrontend
}
if err != nil || json.Unmarshal(wire, &served) != nil || len(served) != 1 || served[0].Proxmox.DiskStatusReason != vm.DiskStatusReason || strings.Contains(string(wire), "diskObservation") {
t.Fatalf("served completeness differs or private observation leaked: %s / %v", wire, err)
}
if served[0].CPU == nil || served[0].CPU.Current != 20 || served[0].Memory == nil || served[0].Memory.Current != 40 {
t.Fatalf("independent current CPU/memory metrics disappeared: %s", wire)
}
metric := served[0].Disk
if vm.Disk.Usage < 0 {
if metric != nil {
t.Fatal("unavailable disk reading became a measured wire zero")
}
} else if metric == nil || metric.Total == nil || metric.Used == nil || *metric.Total != vm.Disk.Total || *metric.Used != vm.Disk.Used || metric.Current != vm.Disk.Usage {
t.Fatalf("known disk tuple or genuine measured zero lost on the wire: %s", wire)
}
return vm
}
initial := build()
if initial.Disk.Total != 10000 || initial.Disk.Used != 9800 || initial.Disk.Usage != 98 || len(initial.Disks) != 2 || initial.DiskStatusReason != "" || initial.DiskObservation.ObservedAt.IsZero() {
t.Fatalf("initial complete reading missing: %+v", initial)
}
active := m.alertManager.GetActiveAlerts()
if len(active) != 2 {
t.Fatalf("complete high readings did not establish two filesystem breaches: %+v", active)
}
breaches := make(map[string]time.Time)
for _, alert := range active {
breaches[alert.ID] = alert.StartTime
}
originalHistory := guestHistoryStoredPoints(t, m, "vm", id, "disk")
if len(originalHistory) != 1 || originalHistory[0].Value != 98 {
t.Fatal("complete reading did not establish History")
}
phase.Store(1)
for i := 0; i < 3; i++ {
retained := build()
if retained.Disk != initial.Disk || !reflect.DeepEqual(retained.Disks, initial.Disks) || retained.DiskStatusReason != "prev-agent-error" || retained.DiskObservation != initial.DiskObservation {
t.Fatalf("partial poll %d became fresh or removed an unknown volume: %+v", i, retained)
}
}
aged := m.previousGuestContextForInstance("partial-filesystems").vmsByID[id]
aged.DiskObservation.ObservedAt = time.Now().Add(-recentGuestAgentEvidenceMaxAge - time.Second)
publish(aged)
expired := build()
if expired.Disk.Usage != -1 || expired.DiskStatusReason != "agent-error" || len(expired.Disks) != 0 || !expired.DiskObservation.ObservedAt.IsZero() {
t.Fatal("repeated partial responses renewed expired complete evidence")
}
active = m.alertManager.GetActiveAlerts()
if len(active) != 2 {
t.Fatalf("partial/expired evidence cleared a genuine filesystem breach: %+v", active)
}
for _, alert := range active {
if !alert.StartTime.Equal(breaches[alert.ID]) {
t.Fatal("partial reading replaced a breach occurrence")
}
}
if !reflect.DeepEqual(guestHistoryStoredPoints(t, m, "vm", id, "disk"), originalHistory) || len(m.metricsHistory.GetGuestMetrics(id, "disk", time.Hour)) != 1 || len(m.metricsHistory.GetGuestMetrics(id, "cpu", time.Hour)) != 5 || fsCalls.Load() != 5 {
t.Fatal("partial evidence created disk History, stopped independent CPU History or retried a command")
}
// Complete ordinary replacement (the large volume really absent) and
// measured zero must still remove/recover. History has second keys.
time.Sleep(1100 * time.Millisecond)
phase.Store(2)
recovered := build()
if recovered.Disk.Total != 1000 || recovered.Disk.Used != 0 || recovered.Disk.Usage != 0 || len(recovered.Disks) != 1 || recovered.DiskStatusReason != "" || !recovered.DiskObservation.ObservedAt.After(initial.DiskObservation.ObservedAt) || len(m.alertManager.GetActiveAlerts()) != 0 || fsCalls.Load() != 6 {
t.Fatalf("complete ordinary removal/zero failed to recover: %+v", recovered)
}
points := guestHistoryStoredPoints(t, m, "vm", id, "disk")
if len(points) != 2 || points[0] != originalHistory[0] || points[1].Value != 0 {
t.Fatal("History lost high reading or failed to append genuine zero")
}
})
}
}
func TestVMFilesystemIncompleteColdPollRemainsUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/get-fsinfo"):
fmt.Fprint(w, `{"data":{"result":[{"mountpoint":"/","type":"ext4","total-bytes":1000,"used-bytes":0},{"mountpoint":"/data","type":"ext4","total-bytes":9000}]}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
res := proxmox.ClusterResource{Node: "node", VMID: 105, MaxDisk: 10000}
_, _, _, usage, disks, fresh, reason := (&Monitor{}).updateVMDisksFromGuestAgentFSInfo(context.Background(), "cold-partial", res, client, 10000, 0, -1)
if usage != -1 || fresh || len(disks) != 0 || reason != "agent-error" {
t.Fatalf("cold partial result became healthy usage: %v/%+v/%t/%q", usage, disks, fresh, reason)
}
}
@@ -0,0 +1,63 @@
package monitoring
import (
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// mockMetricsTarget resolves a canonical resource ID to its metrics-store
// target in mock mode. The second result is false when mock mode is off, so
// the caller takes its ordinary path; when it is true the answer is final for
// this structure, a nil target meaning the view captured no metrics target for
// the ID (the estate does not list it, or lists a resource that has none, such
// as a VMware network); the caller still tries the raw resource store.
//
// Alert evaluation resolves a target for every resource and every windowed
// metric (CPU by default) on every pass, through MetricsTargetForResource.
// The mock view that answers it is cached per fixture data version, and that
// version advances on every metric tick (2 s by default). Rebuilding the view
// ingests the whole estate twice (the memoized fixture snapshot, then the
// view's own registry), about half a second of CPU for the default estate
// (1,776 resources) on an idle core. A build that takes longer than one tick publishes
// its view under an already-stale version, so the next lookup rebuilds again:
// on a host with CPU to spare that costs one build per tick, but when the
// process is starved (a loaded dev VM, a small demo host) the cache never
// hits, every lookup in the pass builds an estate, and the monitor never
// finishes its first evaluation. Startup stalled in exactly that state,
// before the listener opened, with the watchdog dump parked in the view build
// beneath evaluateMetricWindow.
//
// A metrics target depends only on identity inputs: a resource's type, its
// source mappings, and the technology, Docker container ID and disk identity
// of its facets (unifiedresources.BuildMetricsTarget), falling back to the
// resource's own MetricsTarget. A metric tick moves values and timestamps,
// never those inputs (TestMetricTicksNeverChangeTheFixtureStructure), so the
// captured targets of a view built at any data version of the current
// structure answer the lookup. A data-version change alone therefore never
// triggers a build here: only a structural change (mock mode toggled, the
// fixture reconfigured) or a link change makes the next lookup rebuild the
// view. Two lookups that miss together can each still build one, and a cold
// cache builds even for an ID the estate does not list.
func (m *Monitor) mockMetricsTarget(resourceID string) (*unifiedresources.MetricsTarget, bool) {
if m == nil || !mock.IsMockEnabled() {
return nil, false
}
structure := mock.FixtureStructureRevision()
links := m.resourceStoreManualLinks()
m.mockUnifiedViewMu.Lock()
view := m.mockUnifiedView
reusable := m.mockUnifiedViewValid &&
m.mockUnifiedViewStructure == structure &&
sameManualLinks(m.mockUnifiedViewLinks, links)
m.mockUnifiedViewMu.Unlock()
if !reusable {
view = m.currentUnifiedStateView()
}
if view.metricsTargets == nil {
return nil, true
}
return view.metricsTargets.MetricsTargetForResource(resourceID), true
}
@@ -0,0 +1,158 @@
package monitoring
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// useMockEstate enables the default mock estate with the given node count,
// whose fixture ticks every interval, and restores the previous mock mode and
// configuration when the test ends.
func useMockEstate(tb testing.TB, nodes int, interval time.Duration) {
tb.Helper()
previous := mock.IsMockEnabled()
previousConfig := mock.GetConfig()
if previous {
mustSetMockEnabled(tb, false)
}
// Start from the default estate, not the ambient one: PULSE_MOCK_* settings
// can remove the standalone agents and clusters these tests look for.
config := mock.DefaultConfig
config.NodeCount = nodes
config.UpdateInterval = interval
mock.SetMockConfig(config)
mustSetMockEnabled(tb, true)
tb.Cleanup(func() {
mustSetMockEnabled(tb, false)
mock.SetMockConfig(previousConfig)
mustSetMockEnabled(tb, previous)
})
}
func newMockEstateMonitor() *Monitor {
return &Monitor{
state: models.NewState(),
resourceStore: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(unifiedresources.NewMemoryStore())),
}
}
// publishedMockView returns the view the monitor currently caches for mock
// mode without building one.
func publishedMockView(m *Monitor) (monitorUnifiedStateView, bool) {
m.mockUnifiedViewMu.Lock()
defer m.mockUnifiedViewMu.Unlock()
return m.mockUnifiedView, m.mockUnifiedViewValid
}
// sameMetricsTarget reports whether two lookups resolved the same target (or
// both resolved none).
func sameMetricsTarget(a, b *unifiedresources.MetricsTarget) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func waitForFixtureTicks(tb testing.TB, ticks uint64) {
tb.Helper()
target := mock.FixtureDataVersion() + ticks
// A starved host ticks slowly; the deadline only catches a ticker that
// is not running at all.
deadline := time.Now().Add(5 * time.Minute)
for mock.FixtureDataVersion() < target {
if time.Now().After(deadline) {
tb.Fatalf("fixture data version stayed below %d (at %d) for five minutes; the mock ticker is not running", target, mock.FixtureDataVersion())
}
time.Sleep(20 * time.Millisecond)
}
}
func resourceIDsWithTargets(tb testing.TB, view monitorUnifiedStateView) []string {
tb.Helper()
ids := make([]string, 0, len(view.resources))
for _, resource := range view.resources {
if view.metricsTargets.MetricsTargetForResource(resource.ID) != nil {
ids = append(ids, resource.ID)
}
}
if len(ids) == 0 {
tb.Fatal("expected the mock estate to list resources with metrics targets")
}
return ids
}
// BenchmarkMockMetricsTargetResolutionAfterTick measures one alert pass's
// lookups (every resource, three windowed metrics) against a view that was
// built before the fixture last ticked. It must stay a map read per lookup, so
// a pass costs microseconds however slowly the process runs, not an estate
// build per lookup.
func BenchmarkMockMetricsTargetResolutionAfterTick(b *testing.B) {
useMockEstate(b, mock.DefaultConfig.NodeCount, time.Second)
m := newMockEstateMonitor()
ids := resourceIDsWithTargets(b, m.currentUnifiedStateView())
waitForFixtureTicks(b, 1)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, id := range ids {
for metric := 0; metric < 3; metric++ {
if m.MetricsTargetForResource(id) == nil {
b.Fatalf("no target for %q", id)
}
}
}
}
b.ReportMetric(float64(len(ids)*3), "lookups/op")
}
// BenchmarkLiveRegistryMetricsTargetResolution measures the same pass against a
// store-backed registry, as real mode resolves it, right after an ingest (the
// registry's typed views not yet built) and once they are.
func BenchmarkLiveRegistryMetricsTargetResolution(b *testing.B) {
useMockEstate(b, mock.DefaultConfig.NodeCount, 5*time.Minute)
graph := mock.CurrentFixtureGraph()
snapshot := unifiedresources.SnapshotWithoutSources(graph.State, mock.SupplementalOwnedSources())
mustSetMockEnabled(b, false)
for _, views := range []string{"after-ingest", "views-built"} {
b.Run(views, func(b *testing.B) {
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
adapter := unifiedresources.NewMonitorAdapter(registry)
m := &Monitor{state: models.NewState(), resourceStore: adapter}
listed := registry.List()
ids := make([]string, 0, len(listed))
for _, resource := range listed {
ids = append(ids, resource.ID)
}
if views == "views-built" {
adapter.VMs()
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if views == "after-ingest" {
b.StopTimer()
registry = unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
adapter = unifiedresources.NewMonitorAdapter(registry)
m.resourceStore = adapter
b.StartTimer()
}
for _, id := range ids {
for metric := 0; metric < 3; metric++ {
m.MetricsTargetForResource(id)
}
}
}
b.ReportMetric(float64(len(ids)*3), "lookups/op")
})
}
}
+22 -5
View File
@@ -1216,6 +1216,7 @@ type Monitor struct {
mockUnifiedViewMu sync.Mutex
mockUnifiedView monitorUnifiedStateView
mockUnifiedViewVersion uint64
mockUnifiedViewStructure uint64 // fixture structure revision the published view was built at
mockUnifiedViewLinks []unifiedresources.ResourceLink
mockUnifiedViewFixtureAt time.Time // fixture freshness the published view was built from
mockUnifiedViewValid bool
@@ -5049,10 +5050,20 @@ func (m *Monitor) MetricsTargetForResource(resourceID string) *unifiedresources.
return nil
}
if view := m.GetUnifiedReadStateOrSnapshot(); view != nil {
if resolver, ok := view.(MetricsTargetResourceStore); ok {
if target := resolver.MetricsTargetForResource(resourceID); target != nil {
return target
// Mock mode resolves from the cached view's captured targets and never
// asks the view again: this runs once per resource and windowed metric in
// every alert pass, and the fixture's data version moves on every tick
// (see mockMetricsTarget).
target, fromMockView := m.mockMetricsTarget(resourceID)
if target != nil {
return target
}
if !fromMockView {
if view := m.GetUnifiedReadStateOrSnapshot(); view != nil {
if resolver, ok := view.(MetricsTargetResourceStore); ok {
if target := resolver.MetricsTargetForResource(resourceID); target != nil {
return target
}
}
}
}
@@ -5210,7 +5221,12 @@ func (m *Monitor) currentUnifiedStateView() monitorUnifiedStateView {
// Read the version and links before the snapshot so a tick or
// rebuild landing in between caches newer data under an older
// token (harmless rebuild next call) rather than ever serving stale
// data under a newer one.
// data under a newer one. The structure revision goes first: the
// fixture advances its data version before its revision, so a
// reader that sees a new revision also reads the new version and
// never memoizes the previous estate under it
// (mock.FixtureStructureRevision).
structure := mock.FixtureStructureRevision()
version := mock.FixtureDataVersion()
links := m.resourceStoreManualLinks()
m.mockUnifiedViewMu.Lock()
@@ -5255,6 +5271,7 @@ func (m *Monitor) currentUnifiedStateView() monitorUnifiedStateView {
m.mockUnifiedView = view
m.mockUnifiedViewFixtureAt = fixtureFreshness
m.mockUnifiedViewVersion = version
m.mockUnifiedViewStructure = structure
m.mockUnifiedViewLinks = links
m.mockUnifiedViewValid = true
m.mockUnifiedViewMu.Unlock()
@@ -5992,6 +5992,187 @@ func TestMockUnifiedViewAppliesOperatorManualLinks(t *testing.T) {
}
}
// Alert evaluation resolves a target per resource and windowed metric on every
// pass. A metric tick moves the fixture's data version, not what resources
// exist, so a lookup after a tick must be served from the cached view instead
// of building an estate. Before this was pinned, every lookup after a tick
// rebuilt the view, and a process too starved to finish one build within a tick
// rebuilt it for every lookup of the pass: startup never reached its listener.
func TestMockMetricsTargetLookupsDoNotRebuildTheViewAfterFixtureTicks(t *testing.T) {
useMockEstate(t, 4, time.Second)
m := newMockEstateMonitor()
warm := m.currentUnifiedStateView()
ids := resourceIDsWithTargets(t, warm)
waitForFixtureTicks(t, 2)
for _, id := range ids {
want := warm.metricsTargets.MetricsTargetForResource(id)
got := m.MetricsTargetForResource(id)
if !sameMetricsTarget(got, want) {
t.Fatalf("MetricsTargetForResource(%q) = %+v after fixture ticks, want %+v", id, got, want)
}
}
after, ok := publishedMockView(m)
if !ok || after.readState != warm.readState {
t.Fatal("target lookups rebuilt the unified view after a metric tick; they must reuse the view built for the current fixture structure")
}
}
// The reused view answers for the estate it was built from. A structural
// change (here a larger estate) must reach the lookup, not keep serving the
// old resource set.
func TestMockMetricsTargetFollowsAStructuralFixtureChange(t *testing.T) {
useMockEstate(t, 3, 5*time.Minute)
m := newMockEstateMonitor()
before := m.currentUnifiedStateView()
known := make(map[string]struct{}, len(before.resources))
for _, resource := range before.resources {
known[resource.ID] = struct{}{}
}
grown := mock.GetConfig()
grown.NodeCount++
mock.SetMockConfig(grown)
// Another monitor's view of the grown estate names the resources the
// first one lacks. The first monitor has not been asked for a view since
// the change, so the lookup itself has to notice the structure moved.
var added string
for _, resource := range newMockEstateMonitor().currentUnifiedStateView().resources {
if _, ok := known[resource.ID]; !ok && resource.Type == unifiedresources.ResourceTypeAgent {
added = resource.ID
break
}
}
if added == "" {
t.Fatal("the larger fixture estate lists no agent the first one did not")
}
if target := m.MetricsTargetForResource(added); target == nil {
t.Fatalf("MetricsTargetForResource(%q) = nil after the fixture grew; the cached view outlived its structure", added)
}
}
// Reusing a view across ticks must not change an answer: every resource
// resolves to what the view's own registry resolves, and an ID the estate does
// not list resolves to nothing without building a view for it.
func TestMockMetricsTargetMatchesTheRegistryResolution(t *testing.T) {
useMockEstate(t, 4, 5*time.Minute)
m := newMockEstateMonitor()
view := m.currentUnifiedStateView()
registryResolver, ok := view.readState.(MetricsTargetResourceStore)
if !ok {
t.Fatalf("mock view read state %T does not resolve metrics targets", view.readState)
}
for _, resource := range view.resources {
want := registryResolver.MetricsTargetForResource(resource.ID)
got := m.MetricsTargetForResource(resource.ID)
if !sameMetricsTarget(got, want) {
t.Fatalf("MetricsTargetForResource(%q) = %+v, want the registry's %+v", resource.ID, got, want)
}
if padded := m.MetricsTargetForResource(" " + resource.ID + " "); !sameMetricsTarget(padded, want) {
t.Fatalf("MetricsTargetForResource(%q padded) = %+v, want the canonical ID's %+v", resource.ID, padded, want)
}
}
if got := m.MetricsTargetForResource("not-a-mock-resource"); got != nil {
t.Fatalf("MetricsTargetForResource(unknown) = %+v, want nil", got)
}
if after, _ := publishedMockView(m); after.readState != view.readState {
t.Fatal("resolving an unknown ID rebuilt the cached view")
}
}
// A lookup for an ID the estate does not list must stay cheap after a tick
// too: the view has no more to say about it than the one it replaces.
func TestMockMetricsTargetForUnknownIDDoesNotRebuildTheViewAfterFixtureTicks(t *testing.T) {
useMockEstate(t, 3, time.Second)
m := newMockEstateMonitor()
warm := m.currentUnifiedStateView()
waitForFixtureTicks(t, 2)
for i := 0; i < 50; i++ {
if got := m.MetricsTargetForResource(fmt.Sprintf("unknown-%d", i)); got != nil {
t.Fatalf("MetricsTargetForResource(unknown) = %+v, want nil", got)
}
}
if after, _ := publishedMockView(m); after.readState != warm.readState {
t.Fatal("unknown-ID lookups rebuilt the unified view after a metric tick")
}
}
// An operator link folds one resource into another, which changes what the
// folded ID resolves to. Whatever rebuilds the view first, a lookup after the
// resource store applied the link must not resolve the folded resource.
func TestMockMetricsTargetFollowsAnOperatorLink(t *testing.T) {
useMockEstate(t, 4, 5*time.Minute)
store := unifiedresources.NewMemoryStore()
m := &Monitor{
state: models.NewState(),
resourceStore: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store)),
alertManager: alerts.NewManager(),
}
t.Cleanup(m.alertManager.Stop)
var vmID, agentID string
for _, resource := range m.currentUnifiedStateView().resources {
switch {
case vmID == "" && resource.Type == unifiedresources.ResourceTypeVM && resource.Agent == nil:
vmID = resource.ID
case agentID == "" && resource.Type == unifiedresources.ResourceTypeAgent && resource.Agent != nil &&
len(resource.Sources) == 1 && resource.Sources[0] == unifiedresources.SourceAgent:
agentID = resource.ID
}
}
if vmID == "" || agentID == "" {
t.Fatalf("fixture graph needs an agentless VM and a standalone agent, got vm=%q agent=%q", vmID, agentID)
}
if m.MetricsTargetForResource(agentID) == nil || m.MetricsTargetForResource(vmID) == nil {
t.Fatal("expected the unlinked VM and agent to resolve metrics targets")
}
if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: vmID, ResourceB: agentID, PrimaryID: vmID}); err != nil {
t.Fatalf("AddLink: %v", err)
}
// Rebuild the resource store so it loads the link, without asking for a
// mock view: the lookup itself has to see the link list change.
m.updateResourceStore(models.StateSnapshot{}, m.mockModeFence.begin())
if got := m.MetricsTargetForResource(agentID); got != nil {
t.Fatalf("MetricsTargetForResource(folded agent) = %+v after the operator link, want nothing", got)
}
if m.MetricsTargetForResource(vmID) == nil {
t.Fatal("the linked VM lost its metrics target")
}
}
// The reused view was built under one link list; once the resource store
// applies a different one, a lookup must rebuild rather than answer from it.
// A rebuild by any other consumer would hide a missing check, so the list the
// view was built under is changed directly.
func TestMockMetricsTargetRebuildsWhenTheLinkListMoves(t *testing.T) {
useMockEstate(t, 3, 5*time.Minute)
m := newMockEstateMonitor()
warm := m.currentUnifiedStateView()
id := resourceIDsWithTargets(t, warm)[0]
m.mockUnifiedViewMu.Lock()
m.mockUnifiedViewLinks = []unifiedresources.ResourceLink{{ResourceA: "stale-a", ResourceB: "stale-b", PrimaryID: "stale-a"}}
m.mockUnifiedViewMu.Unlock()
if m.MetricsTargetForResource(id) == nil {
t.Fatalf("MetricsTargetForResource(%q) = nil", id)
}
after, _ := publishedMockView(m)
if after.readState == warm.readState {
t.Fatal("a lookup answered from a view built under a different operator link list")
}
}
// TestMockModeDiscardsRealHostReports pins the push-side of the mock clean
// room. Mock mode already suspends pull-based PVE/PBS/PMG collection, but a
// real agent keeps POSTing regardless; ingesting those reports lands a real
@@ -890,8 +890,10 @@ func (m *Monitor) updateVMDisksFromGuestAgentFSInfo(
summary := m.summarizeVMFSInfo(instanceName, res, fsInfo)
// If we got valid data from guest agent, use it
if summary.totalBytes > 0 {
// Valid peer rows are not complete guest usage if any reading was rejected.
// The wire client reports that distinction as an error; alternate clients
// can supply invalid counters directly, so enforce it at this boundary too.
if summary.totalBytes > 0 && !summary.invalidBytes {
// Sanity check: if the reported disk is way larger than allocated disk,
// we might be getting host disk info somehow
allocatedDiskGB := float64(res.MaxDisk) / 1073741824
+9 -3
View File
@@ -273,8 +273,11 @@ func TestCloneResource_MutateParentBySource(t *testing.T) {
// registry's listing can name each link's pair, and stay detached from them.
func TestCloneResource_CarriesLinkFolds(t *testing.T) {
original := &Resource{
ID: "vm-1",
linkFolds: []ManualLinkFold{{HolderID: "vm-1", FoldedID: "agent-1", Sources: []DataSource{SourceAgent}}},
ID: "vm-1",
linkFolds: []ManualLinkFold{{
HolderID: "vm-1", FoldedID: "agent-1", Sources: []DataSource{SourceAgent},
HolderOwn: []DataSource{SourceProxmox}, FoldedOwn: []DataSource{SourceAgent},
}},
}
cloned := cloneResource(original)
if len(cloned.linkFolds) != 1 || cloned.linkFolds[0].FoldedID != "agent-1" {
@@ -282,8 +285,11 @@ func TestCloneResource_CarriesLinkFolds(t *testing.T) {
}
cloned.linkFolds[0].Sources[0] = SourceDocker
cloned.linkFolds[0].HolderOwn[0] = SourceDocker
cloned.linkFolds[0].FoldedOwn[0] = SourceDocker
cloned.linkFolds[0].FoldedID = "MUTATED"
if original.linkFolds[0].Sources[0] != SourceAgent || original.linkFolds[0].FoldedID != "agent-1" {
if original.linkFolds[0].Sources[0] != SourceAgent || original.linkFolds[0].FoldedID != "agent-1" ||
original.linkFolds[0].HolderOwn[0] != SourceProxmox || original.linkFolds[0].FoldedOwn[0] != SourceAgent {
t.Errorf("mutating cloned link folds affected the original: %+v", original.linkFolds)
}
}
@@ -771,6 +771,7 @@ func TestResourceAPIReportMergeExcludesRegistryLinkFolds(t *testing.T) {
for _, snippet := range []string{
"linkFolds := registry.ManualLinkFolds(path)",
"if len(resource.Sources) < 2 && len(linkFolds) == 0 {",
"for _, fold := range unified.ReportedManualLinkFolds(path, linkFolds, selected) {",
"pairs = append(pairs, exclusionPair{fold.HolderID, fold.FoldedID})",
} {
if !strings.Contains(source, snippet) {
+147 -7
View File
@@ -6,11 +6,17 @@ package unifiedresources
// (the store keeps one decision per pair). A candidate ID derived from the
// merged resource's type cannot: for an agent linked into a VM it is a vm- ID
// that names neither side. Sources lists what the folded side brought,
// including anything links had already folded into it.
// including anything links had already folded into it. HolderOwn and
// FoldedOwn list what each side is on its own: the sources its row listed
// before any link folded something into it. A row that took other members in
// lists theirs too, so Sources alone cannot say which member of a chain a
// source belongs to.
type ManualLinkFold struct {
HolderID string
FoldedID string
Sources []DataSource
HolderID string
FoldedID string
Sources []DataSource
HolderOwn []DataSource
FoldedOwn []DataSource
}
// recordManualLinkFold notes on the link's merged resource that it took in
@@ -24,13 +30,19 @@ type ManualLinkFold struct {
// with fewer sources than it brought before, so a repeated pair adds its
// sources to the fold already recorded. The holder still carries what the
// earlier fold brought.
//
// Call it before the other side's data is merged into primary: the sources
// primary lists at that point are its own unless an earlier fold on it
// recorded them (linkMemberOwnSources).
func recordManualLinkFold(primary *Resource, primaryID string, other *Resource, otherID string) {
added := make([]ManualLinkFold, 0, len(other.linkFolds)+1)
added = append(added, other.linkFolds...)
added = append(added, ManualLinkFold{
HolderID: primaryID,
FoldedID: otherID,
Sources: cloneDataSourceSlice(other.Sources),
HolderID: primaryID,
FoldedID: otherID,
Sources: cloneDataSourceSlice(other.Sources),
HolderOwn: linkMemberOwnSources(primary, primaryID),
FoldedOwn: linkMemberOwnSources(other, otherID),
})
folds := make([]ManualLinkFold, 0, len(primary.linkFolds)+len(added))
recorded := make(map[string]int, len(primary.linkFolds)+len(added))
@@ -38,6 +50,12 @@ func recordManualLinkFold(primary *Resource, primaryID string, other *Resource,
key := exclusionKey(fold.HolderID, fold.FoldedID)
if i, ok := recorded[key]; ok {
folds[i].Sources = addSources(cloneDataSourceSlice(folds[i].Sources), fold.Sources)
holderOwn, foldedOwn := fold.HolderOwn, fold.FoldedOwn
if folds[i].HolderID != fold.HolderID {
holderOwn, foldedOwn = foldedOwn, holderOwn
}
folds[i].HolderOwn = addSources(cloneDataSourceSlice(folds[i].HolderOwn), holderOwn)
folds[i].FoldedOwn = addSources(cloneDataSourceSlice(folds[i].FoldedOwn), foldedOwn)
return
}
recorded[key] = len(folds)
@@ -72,7 +90,129 @@ func cloneManualLinkFolds(in []ManualLinkFold) []ManualLinkFold {
out := make([]ManualLinkFold, len(in))
for i, fold := range in {
fold.Sources = cloneDataSourceSlice(fold.Sources)
fold.HolderOwn = cloneDataSourceSlice(fold.HolderOwn)
fold.FoldedOwn = cloneDataSourceSlice(fold.FoldedOwn)
out[i] = fold
}
return out
}
// linkMemberOwnSources returns what the member id is on its own: the sources
// an earlier fold on row recorded for it, else the sources row lists, which
// are its own while no link has folded anything into it.
func linkMemberOwnSources(row *Resource, id string) []DataSource {
for _, fold := range row.linkFolds {
if fold.HolderID == id && fold.HolderOwn != nil {
return cloneDataSourceSlice(fold.HolderOwn)
}
if fold.FoldedID == id && fold.FoldedOwn != nil {
return cloneDataSourceSlice(fold.FoldedOwn)
}
}
return cloneDataSourceSlice(row.Sources)
}
// ReportedManualLinkFolds picks, from the folds of the merged resource rootID,
// the links a report-merge undoes. reported says whether any of the sources
// it is given was named by the report; a report naming no source reports
// every source.
//
// A report names sources, and the sources belong to link members: the
// resources the folds' pairs name. A member is reported when its own sources
// include a reported one, whatever it took in; the resource being reported on
// is never one, since it is what stays. Reported members leave the part of
// the chain that stays, so a link is undone when it joins a reported member
// to a member that stays or to another reported member. A member that hangs
// below a reported one and carries no reported source goes with it, and the
// link that holds it there stays: the report names a source that is not
// theirs to undo. A member that stays is one the root still reaches without
// passing through a reported member, which matters where links form a cycle:
// a reported member linked both to the root and to a member that stays is cut
// from both, so it cannot rejoin through the other.
//
// A fold's Sources lists its folded side's whole subtree, so selecting folds
// by them undid every link along the way to a reported source: a source
// brought by a leaf split the leaf's holder from the root as well.
func ReportedManualLinkFolds(rootID string, folds []ManualLinkFold, reported func(...DataSource) bool) []ManualLinkFold {
if len(folds) == 0 {
return nil
}
// A member named by several folds takes the union of what they record: a
// repeated pair unions into its own fold only, so a member whose refold
// gained a source would otherwise read differently from fold to fold.
own := make(map[string][]DataSource, len(folds)+1)
adjacent := make(map[string][]string, len(folds)+1)
folded := make(map[string]bool, len(folds))
for _, fold := range folds {
if fold.HolderOwn != nil {
own[fold.HolderID] = addSources(own[fold.HolderID], fold.HolderOwn)
}
if fold.FoldedOwn != nil {
own[fold.FoldedID] = addSources(own[fold.FoldedID], fold.FoldedOwn)
}
adjacent[fold.HolderID] = append(adjacent[fold.HolderID], fold.FoldedID)
adjacent[fold.FoldedID] = append(adjacent[fold.FoldedID], fold.HolderID)
folded[fold.FoldedID] = true
}
// A fold recorded without its sides' own sources lists the folded side's
// whole subtree, the most that side can have been.
for _, fold := range folds {
if _, ok := own[fold.FoldedID]; !ok {
own[fold.FoldedID] = fold.Sources
}
}
root := rootID
if _, ok := adjacent[root]; !ok {
// The root was re-keyed after the links folded: the one holder no
// link folded is the resource the records hang from.
root = ""
for _, fold := range folds {
if folded[fold.HolderID] {
continue
}
if root != "" && root != fold.HolderID {
root = ""
break
}
root = fold.HolderID
}
}
if root == "" {
var selected []ManualLinkFold
for _, fold := range folds {
if reported(fold.Sources...) {
selected = append(selected, fold)
}
}
return selected
}
away := make(map[string]bool, len(adjacent))
for id := range adjacent {
if id != root && reported(own[id]...) {
away[id] = true
}
}
stays := map[string]bool{root: true}
for queue := []string{root}; len(queue) > 0; {
current := queue[0]
queue = queue[1:]
for _, next := range adjacent[current] {
if away[next] || stays[next] {
continue
}
stays[next] = true
queue = append(queue, next)
}
}
carried := func(id string) bool { return !away[id] && !stays[id] }
var selected []ManualLinkFold
for _, fold := range folds {
if (away[fold.HolderID] || away[fold.FoldedID]) && !carried(fold.HolderID) && !carried(fold.FoldedID) {
selected = append(selected, fold)
}
}
return selected
}
+3 -1
View File
@@ -5068,8 +5068,10 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura
rr.recordLinkOwnPin(primaryID, primary)
rr.recordLinkOwnPin(otherID, other)
rr.mergeResourceData(primary, other, thresholds)
// The fold records what each side is on its own, so it reads the
// primary before the other side's sources join its list.
recordManualLinkFold(primary, primaryID, other, otherID)
rr.mergeResourceData(primary, other, thresholds)
delete(rr.resources, otherID)
// The fold record names every ID along a chain of links, so the chain
// resolves to its last primary; the folded row's own index entries
@@ -1958,9 +1958,17 @@ func TestManualLinkFoldsNameEachLinkPairAlongAChain(t *testing.T) {
// The agent linked from its own side still folds into the guest it runs
// in, and the fold names the link's pair either way.
// Each side also records what it is on its own: the agent's fold into the VM
// lists the Docker host's source too, but the agent is only the agent.
want := []ManualLinkFold{
{HolderID: agentID, FoldedID: dockerID, Sources: []DataSource{SourceDocker}},
{HolderID: vmID, FoldedID: agentID, Sources: []DataSource{SourceAgent, SourceDocker}},
{
HolderID: agentID, FoldedID: dockerID, Sources: []DataSource{SourceDocker},
HolderOwn: []DataSource{SourceAgent}, FoldedOwn: []DataSource{SourceDocker},
},
{
HolderID: vmID, FoldedID: agentID, Sources: []DataSource{SourceAgent, SourceDocker},
HolderOwn: []DataSource{SourceProxmox}, FoldedOwn: []DataSource{SourceAgent},
},
}
if got := adapter.currentRegistry().ManualLinkFolds(vmID); !reflect.DeepEqual(got, want) {
t.Fatalf("monitor folds = %+v, want %+v", got, want)
@@ -2023,7 +2031,10 @@ func TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests(t *testing.T) {
if listed := rr.List(); len(listed) != 1 {
t.Fatalf("link did not fold the TrueNAS system into the VM: %+v", listed)
}
want := []ManualLinkFold{{HolderID: vmID, FoldedID: systemID, Sources: []DataSource{SourceTrueNAS}}}
want := []ManualLinkFold{{
HolderID: vmID, FoldedID: systemID, Sources: []DataSource{SourceTrueNAS},
HolderOwn: []DataSource{SourceProxmox}, FoldedOwn: []DataSource{SourceTrueNAS},
}}
if got := rr.ManualLinkFolds(vmID); !reflect.DeepEqual(got, want) {
t.Fatalf("folds after repeated record ingests = %+v, want %+v", got, want)
}
@@ -2037,12 +2048,184 @@ func TestManualLinkFoldRepeatedPairKeepsEarlierSources(t *testing.T) {
holder := &Resource{ID: "vm-1"}
recordManualLinkFold(holder, "vm-1", &Resource{ID: "agent-1", Sources: []DataSource{SourceAgent, SourceDocker}}, "agent-1")
recordManualLinkFold(holder, "vm-1", &Resource{ID: "agent-1", Sources: []DataSource{SourceAgent}}, "agent-1")
want := []ManualLinkFold{{HolderID: "vm-1", FoldedID: "agent-1", Sources: []DataSource{SourceAgent, SourceDocker}}}
want := []ManualLinkFold{{
HolderID: "vm-1", FoldedID: "agent-1", Sources: []DataSource{SourceAgent, SourceDocker},
FoldedOwn: []DataSource{SourceAgent, SourceDocker},
}}
if !reflect.DeepEqual(holder.linkFolds, want) {
t.Fatalf("folds = %+v, want %+v", holder.linkFolds, want)
}
}
// A holder that already took a member in lists that member's sources too, so
// its own sources come from the record the first fold made, not from its row.
func TestManualLinkFoldOwnSourcesComeFromTheFirstRecord(t *testing.T) {
holder := &Resource{ID: "vm-1", Sources: []DataSource{SourceProxmox}}
first := &Resource{ID: "agent-1", Sources: []DataSource{SourceAgent}}
recordManualLinkFold(holder, "vm-1", first, "agent-1")
holder.Sources = addSources(holder.Sources, first.Sources)
second := &Resource{ID: "docker-1", Sources: []DataSource{SourceDocker}}
recordManualLinkFold(holder, "vm-1", second, "docker-1")
if got := holder.linkFolds[1].HolderOwn; !reflect.DeepEqual(got, []DataSource{SourceProxmox}) {
t.Fatalf("second fold lists the holder's own sources as %v, want only %v", got, SourceProxmox)
}
// The VM then folds into another resource: the member it was keeps its own
// sources, not the ones it took in.
root := &Resource{ID: "node-1", Sources: []DataSource{SourceTrueNAS}}
recordManualLinkFold(root, "node-1", holder, "vm-1")
last := root.linkFolds[len(root.linkFolds)-1]
if last.FoldedID != "vm-1" || !reflect.DeepEqual(last.FoldedOwn, []DataSource{SourceProxmox}) {
t.Fatalf("fold of the holder lists its own sources as %v, want only %v", last.FoldedOwn, SourceProxmox)
}
if !reflect.DeepEqual(last.HolderOwn, []DataSource{SourceTrueNAS}) {
t.Fatalf("fold into the root lists its own sources as %v, want only %v", last.HolderOwn, SourceTrueNAS)
}
}
// ReportedManualLinkFolds picks the links a source-filtered report undoes by
// member. The members here are the estate that exposed the fold subtree
// rule: a TrueNAS VM V takes in a Proxmox storage S and a vSphere VM W, and
// the operator also linked S into W, so recording every link of the chain
// leaves three pairs with W reachable through two of them.
func TestReportedManualLinkFoldsDetachMembersCarryingTheSource(t *testing.T) {
pair := func(holder, folded string, sources, holderOwn, foldedOwn []DataSource) ManualLinkFold {
return ManualLinkFold{HolderID: holder, FoldedID: folded, Sources: sources, HolderOwn: holderOwn, FoldedOwn: foldedOwn}
}
named := func(names ...DataSource) func(...DataSource) bool {
return func(sources ...DataSource) bool {
if len(names) == 0 {
return true
}
for _, source := range sources {
if slices.Contains(names, source) {
return true
}
}
return false
}
}
pairs := func(folds []ManualLinkFold) []string {
out := make([]string, 0, len(folds))
for _, fold := range folds {
out = append(out, fold.HolderID+">"+fold.FoldedID)
}
slices.Sort(out)
return out
}
truenas, proxmox, vmware := []DataSource{SourceTrueNAS}, []DataSource{SourceProxmox}, []DataSource{SourceVMware}
cycle := []ManualLinkFold{
pair("V", "S", proxmox, truenas, proxmox),
pair("V", "W", vmware, truenas, vmware),
pair("W", "S", proxmox, vmware, proxmox),
}
// A leaf below a holder below the root: V takes in A (agent), which took
// in D (docker), so A's fold lists both sources.
agent, docker := []DataSource{SourceAgent}, []DataSource{SourceDocker}
tree := []ManualLinkFold{
pair("A", "D", docker, agent, docker),
pair("V", "A", []DataSource{SourceAgent, SourceDocker}, proxmox, agent),
}
for _, tc := range []struct {
name string
root string
folds []ManualLinkFold
named []DataSource
want []string
}{
{"cycle: member reachable two ways is cut from both", "V", cycle, []DataSource{SourceVMware}, []string{"V>W", "W>S"}},
{"cycle: the storage leaves, the VM keeps the other link", "V", cycle, []DataSource{SourceProxmox}, []string{"V>S", "W>S"}},
{"cycle: both reported members leave", "V", cycle, []DataSource{SourceVMware, SourceProxmox}, []string{"V>S", "V>W", "W>S"}},
{"cycle: the root's own source selects nothing", "V", cycle, []DataSource{SourceTrueNAS}, nil},
{"cycle: no filter undoes every link", "V", cycle, nil, []string{"V>S", "V>W", "W>S"}},
{"tree: a leaf's source leaves its holder's link alone", "V", tree, []DataSource{SourceDocker}, []string{"A>D"}},
{"tree: a holder leaves and keeps what it took in", "V", tree, []DataSource{SourceAgent}, []string{"V>A"}},
{"tree: both reported, both leave", "V", tree, []DataSource{SourceAgent, SourceDocker}, []string{"A>D", "V>A"}},
{"tree: the root's own source selects nothing", "V", tree, []DataSource{SourceProxmox}, nil},
{"tree: no filter undoes every link", "V", tree, nil, []string{"A>D", "V>A"}},
// A refold of the agent unions a source into the VM's fold of it only,
// so the agent's two records disagree; the member is the union.
{"tree: a member whose refold gained the source is reported by it", "V", []ManualLinkFold{
pair("A", "D", docker, agent, docker),
pair("V", "A", []DataSource{SourceAgent, SourceDocker}, proxmox, []DataSource{SourceAgent, SourceDocker}),
}, []DataSource{SourceDocker}, []string{"A>D", "V>A"}},
{"root re-keyed after the links folded", "V-renamed", tree, []DataSource{SourceDocker}, []string{"A>D"}},
{"ambiguous root falls back to the folded subtree", "elsewhere", []ManualLinkFold{
pair("X", "Y", docker, nil, nil), pair("P", "Q", agent, nil, nil),
}, []DataSource{SourceDocker}, []string{"X>Y"}},
{"folds recorded without own sources use the folded subtree", "V", []ManualLinkFold{
{HolderID: "V", FoldedID: "A", Sources: []DataSource{SourceAgent, SourceDocker}},
{HolderID: "A", FoldedID: "D", Sources: docker},
}, []DataSource{SourceDocker}, []string{"A>D", "V>A"}},
} {
t.Run(tc.name, func(t *testing.T) {
got := pairs(ReportedManualLinkFolds(tc.root, tc.folds, named(tc.named...)))
if !slices.Equal(got, tc.want) {
t.Fatalf("report of %v selected %v, want %v", tc.named, got, tc.want)
}
})
}
}
// Whatever links a report undoes, no member carrying a reported source may
// stay joined to the root through the links left. The rule the registry
// replaced selected folds whose subtree sources named the report, and left W
// joined through S in the cycle: its control here fails the same check.
func TestReportedManualLinkFoldsLeaveNoReportedMemberJoined(t *testing.T) {
truenas, proxmox, vmware := []DataSource{SourceTrueNAS}, []DataSource{SourceProxmox}, []DataSource{SourceVMware}
cycle := []ManualLinkFold{
{HolderID: "V", FoldedID: "S", Sources: proxmox, HolderOwn: truenas, FoldedOwn: proxmox},
{HolderID: "V", FoldedID: "W", Sources: vmware, HolderOwn: truenas, FoldedOwn: vmware},
{HolderID: "W", FoldedID: "S", Sources: proxmox, HolderOwn: vmware, FoldedOwn: proxmox},
}
joinedToRoot := func(folds []ManualLinkFold, left map[string]bool, member string) bool {
reached := map[string]bool{"V": true}
for changed := true; changed; {
changed = false
for _, fold := range folds {
if left[exclusionKey(fold.HolderID, fold.FoldedID)] {
continue
}
if reached[fold.HolderID] != reached[fold.FoldedID] {
reached[fold.HolderID], reached[fold.FoldedID] = true, true
changed = true
}
}
}
return reached[member]
}
undone := func(selected []ManualLinkFold) map[string]bool {
out := make(map[string]bool, len(selected))
for _, fold := range selected {
out[exclusionKey(fold.HolderID, fold.FoldedID)] = true
}
return out
}
for _, tc := range []struct {
reported DataSource
member string
}{{SourceVMware, "W"}, {SourceProxmox, "S"}} {
named := func(sources ...DataSource) bool { return slices.Contains(sources, tc.reported) }
got := ReportedManualLinkFolds("V", cycle, named)
if joinedToRoot(cycle, undone(got), tc.member) {
t.Errorf("after a report of %s, %s is still joined to V through %v", tc.reported, tc.member, got)
}
// The control: folds selected by their folded side's subtree.
var bySubtree []ManualLinkFold
for _, fold := range cycle {
if named(fold.Sources...) {
bySubtree = append(bySubtree, fold)
}
}
if tc.reported == SourceVMware && !joinedToRoot(cycle, undone(bySubtree), tc.member) {
t.Errorf("control: selecting by subtree sources detached %s, so the cycle no longer shows the defect", tc.member)
}
}
}
func TestLeaseExpiredKubernetesClusterStaysOffline(t *testing.T) {
rr := NewRegistry(nil)
rr.IngestSnapshot(models.StateSnapshot{
+18 -3
View File
@@ -1706,13 +1706,21 @@ func (fs *VMFileSystem) UnmarshalJSON(data []byte) error {
if !valid {
return fmt.Errorf("guest filesystem record is ambiguous or malformed")
}
recognized := false
for name := range fields {
switch name {
case "name", "type", "mountpoint", "total-bytes", "total-bytes-privileged", "used-bytes", "disk":
recognized = true
}
for _, canonical := range []string{"total-bytes", "total-bytes-privileged", "used-bytes"} {
if name != canonical && strings.EqualFold(name, canonical) {
return fmt.Errorf("guest filesystem byte count has a noncanonical field name")
}
}
}
if !recognized {
return fmt.Errorf("guest filesystem record has no recognized fields")
}
type rawVMFileSystem struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -2023,7 +2031,9 @@ func coerceString(value interface{}) string {
}
}
// GetVMFSInfo returns filesystem information from QEMU guest agent
// GetVMFSInfo returns filesystem information from QEMU guest agent. If any
// record is malformed, valid peers are returned with an error: they cannot
// establish complete guest capacity or removal of the rejected filesystem.
func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-fsinfo", node, vmid))
if err != nil {
@@ -2045,8 +2055,8 @@ func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFi
Str("response", string(bodyBytes)).
Msg("Raw response from guest agent get-fsinfo")
// Decode array payloads entry-by-entry so one malformed filesystem record
// does not wipe valid guest-agent disk data for the whole VM.
// Decode entry-by-entry to retain valid peers, but never mistake that
// remainder for a complete inventory after dropping a malformed record.
var arrayResult struct {
Data struct {
Result []json.RawMessage `json:"result"`
@@ -2054,9 +2064,11 @@ func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFi
}
if err := json.Unmarshal(bodyBytes, &arrayResult); err == nil && arrayResult.Data.Result != nil {
filesystems := make([]VMFileSystem, 0, len(arrayResult.Data.Result))
invalidEntries := 0
for idx, rawFS := range arrayResult.Data.Result {
var fs VMFileSystem
if err := json.Unmarshal(rawFS, &fs); err != nil {
invalidEntries++
log.Warn().
Err(err).
Str("node", node).
@@ -2068,6 +2080,9 @@ func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFi
filesystems = append(filesystems, fs)
}
postProcessVMFilesystems(node, vmid, filesystems)
if invalidEntries > 0 {
return filesystems, fmt.Errorf("guest filesystem inventory is incomplete: %d malformed entries", invalidEntries)
}
return filesystems, nil
}
+2 -2
View File
@@ -261,8 +261,8 @@ func TestClientVMFSInfoSkipsMalformedEntries(t *testing.T) {
ctx := context.Background()
filesystems, err := client.GetVMFSInfo(ctx, "node1", 100)
if err != nil {
t.Fatalf("GetVMFSInfo error: %v", err)
if err == nil {
t.Fatal("malformed peer must prevent a complete filesystem inventory")
}
if len(filesystems) != 1 {
t.Fatalf("expected malformed entry to be skipped, got %+v", filesystems)
+3 -4
View File
@@ -1466,11 +1466,10 @@ func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int)
var result []VMFileSystem
err := cc.executeGuestAgent(ctx, func(client *Client) error {
info, err := client.GetVMFSInfo(ctx, node, vmid)
if err != nil {
return err
}
// Preserve valid peers and the incomplete-inventory error together.
// Neither a partial response nor its error grants another endpoint read.
result = info
return nil
return err
})
return result, err
}
+9 -2
View File
@@ -89,7 +89,7 @@ func TestVMFilesystemBytesWireAdmissionKeepsValidPeersAndObjectPrecision(t *test
total, used uint64
wantError bool
}{
"array-peers": {`[{"mountpoint":"/bad","type":"ext4","total-bytes":1000},{"mountpoint":"/negative","type":"ext4","total-bytes":1000,"used-bytes":-1},{"mountpoint":"/overfull","type":"ext4","total-bytes":1000,"used-bytes":1001},{"mountpoint":"/","type":"ext4","total-bytes":1000,"used-bytes":0}]`, 1000, 0, false},
"array-peers": {`[{"mountpoint":"/bad","type":"ext4","total-bytes":1000},{"mountpoint":"/negative","type":"ext4","total-bytes":1000,"used-bytes":-1},{"mountpoint":"/overfull","type":"ext4","total-bytes":1000,"used-bytes":1001},{"mountpoint":"/","type":"ext4","total-bytes":1000,"used-bytes":0}]`, 1000, 0, true},
"object-precision": {`{"mountpoint":"C:\\","type":"ntfs","total-bytes":9007199254740993,"used-bytes":9007199254740991,"disk":{"bus-type":"scsi","target":2}}`, 9007199254740993, 9007199254740991, false},
"object-duplicate": {`{"mountpoint":"C:\\","type":"ntfs","total-bytes":1000,"used-bytes":1001,"used-bytes":0}`, 0, 0, true},
"object-missing": {`{"mountpoint":"C:\\","type":"ntfs","total-bytes":1000}`, 0, 0, true},
@@ -112,9 +112,16 @@ func TestVMFilesystemBytesWireAdmissionKeepsValidPeersAndObjectPrecision(t *test
c := backupTestClient(t, server.URL)
readings, err := c.GetVMFSInfo(context.Background(), "node", 105)
if tc.wantError {
if err == nil || len(readings) != 0 {
if err == nil {
t.Fatalf("invalid object admitted: %+v %v", readings, err)
}
if name == "array-peers" {
if len(readings) != 1 || readings[0].TotalBytes != tc.total || readings[0].UsedBytes != tc.used {
t.Fatalf("valid peers lost with incomplete inventory error: %+v %v", readings, err)
}
} else if len(readings) != 0 {
t.Fatalf("invalid object returned readings: %+v", readings)
}
} else {
if err != nil || len(readings) != 1 {
t.Fatalf("valid readings lost: %+v %v", readings, err)
@@ -0,0 +1,88 @@
package proxmox
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// A malformed row is not an absent volume. Keep the valid peers, but make the
// completeness failure visible through both ordinary client paths. This is a
// completed synthetic QGA reply, not a native freeze/recovery experiment.
func TestVMFilesystemIncompleteInventoryKeepsPeersAndSingleAttempt(t *testing.T) {
for _, path := range []string{"single", "cluster"} {
for name, bad := range map[string]string{
"missing-used": `{"mountpoint":"/data","type":"ext4","total-bytes":9000}`,
"overused": `{"mountpoint":"/data","type":"ext4","total-bytes":9000,"used-bytes":9001}`,
"duplicate": `{"mountpoint":"/data","type":"ext4","total-bytes":9000,"used-bytes":8800,"used-bytes":0}`,
"null-record": `null`,
"scalar-record": `42`,
"empty-record": `{}`,
"unknown-record": `{"unknown":"value"}`,
} {
t.Run(path+"/"+name, func(t *testing.T) {
var commands, configs atomic.Int32
var corrected atomic.Bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/nodes"):
fmt.Fprint(w, `{"data":[]}`)
case strings.HasSuffix(r.URL.Path, "/config"):
configs.Add(1)
fmt.Fprint(w, `{"data":{}}`)
case strings.Contains(r.URL.Path, "/agent/"):
commands.Add(1)
if strings.HasSuffix(r.URL.Path, "/get-fsinfo") {
peer := `{"mountpoint":"/","type":"ext4","total-bytes":1000,"used-bytes":0}`
if corrected.Load() {
fmt.Fprintf(w, `{"data":{"result":[%s]}}`, peer)
} else {
fmt.Fprintf(w, `{"data":{"result":[%s,%s]}}`, bad, peer)
}
} else {
backupAgentPayload(w, r)
}
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.NotFound(w, r)
}
})
a, b := httptest.NewServer(handler), httptest.NewServer(handler)
defer a.Close()
defer b.Close()
var client interface {
GetVMFSInfo(context.Context, string, int) ([]VMFileSystem, error)
GetVMAgentInfo(context.Context, string, int) (map[string]interface{}, error)
}
if path == "cluster" {
client = NewClusterClient("partial-filesystems", ClientConfig{Host: a.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}, []string{a.URL, b.URL}, nil)
} else {
client = backupTestClient(t, a.URL)
}
peers, err := client.GetVMFSInfo(context.Background(), "node", 105)
if err == nil || errors.Is(err, ErrGuestAgentDeferred) || len(peers) != 1 || peers[0].TotalBytes != 1000 || peers[0].UsedBytes != 0 || peers[0].Disk != "root-filesystem" {
t.Fatalf("partial inventory lost failure or valid peer: %+v / %v", peers, err)
}
if commands.Load() != 1 || configs.Load() != 2 {
t.Fatalf("incomplete payload repeated/bypassed guest admission: commands=%d config=%d", commands.Load(), configs.Load())
}
// Parsing failure is not uncertain command completion. Another
// ordinary metadata read remains eligible, not a forced probe.
if _, err := client.GetVMAgentInfo(context.Background(), "node", 105); err != nil {
t.Fatalf("completed malformed reading started a command cooldown: %v", err)
}
corrected.Store(true)
peers, err = client.GetVMFSInfo(context.Background(), "migrated", 105)
if err != nil || len(peers) != 1 || peers[0].UsedBytes != 0 || commands.Load() != 3 || configs.Load() != 6 {
t.Fatalf("complete ordinary removal/zero did not recover once: %+v / %v / commands=%d config=%d", peers, err, commands.Load(), configs.Load())
}
})
}
}
}
+194 -20
View File
@@ -21,27 +21,80 @@ import (
// Execute the actual reference recipes against synthetic peers only. In
// particular, action examples must not acquire an automatic retry or emit a
// private response just because a POST failed or its response was incomplete.
func pulseAPIRecipes(doc string) (preparation, helper string, calls []string, err error) {
type pulseAPIRequestContract struct {
name, firstLine, method, uri, input string
}
// Match each copied request to its own transport contract, not its position in
// the guide. New examples must gain a fixture rather than disabling every
// existing check with an unexplained recipe-count mismatch.
func pulseAPIRequestContracts() []pulseAPIRequestContract {
return []pulseAPIRequestContract{
{"summary", "pulse_api GET /api/state/summary", "GET", "/api/state/summary", ""},
{"connections", "pulse_api GET /api/connections", "GET", "/api/connections", ""},
{"capabilities", "pulse_api GET /api/agent/resource-capabilities/vm%3A42", "GET", "/api/agent/resource-capabilities/vm%3A42", ""},
{"plan", "pulse_api POST /api/actions/plan <<'JSON'", "POST", "/api/actions/plan", "heredoc"},
{"decision", "pulse_api POST /api/actions/act_.../decision <<'JSON'", "POST", "/api/actions/act_.../decision", "heredoc"},
{"execute", "pulse_api POST /api/actions/act_.../execute <<'JSON'", "POST", "/api/actions/act_.../execute", "heredoc"},
{"audit", "pulse_api GET '/api/audit/actions?resourceId=vm%3A42&limit=10'", "GET", "/api/audit/actions?resourceId=vm%3A42&limit=10", ""},
{"events", "pulse_api GET /api/audit/actions/act_.../events", "GET", "/api/audit/actions/act_.../events", ""},
{"add-node", `pulse_api POST /api/config/nodes < "$node_request_file"`, "POST", "/api/config/nodes", "file"},
}
}
type pulseAPIRecipe struct {
contract pulseAPIRequestContract
command string
}
func pulseAPIRecipes(doc string) (preparation, helper string, calls []pulseAPIRecipe, err error) {
contracts := make(map[string]pulseAPIRequestContract)
for _, contract := range pulseAPIRequestContracts() {
contracts[contract.firstLine] = contract
}
seen := make(map[string]bool)
for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) {
block := match[1]
switch {
case strings.Contains(block, "api-header") && strings.Contains(block, "vi "):
if preparation != "" {
return "", "", nil, fmt.Errorf("duplicate credential preparation recipe")
}
preparation = block
case strings.HasPrefix(block, "pulse_api() ("):
if helper != "" {
return "", "", nil, fmt.Errorf("duplicate private-response helper")
}
helper = block
case strings.HasPrefix(block, "pulse_api "):
calls = append(calls, block)
firstLine, _, _ := strings.Cut(block, "\n")
contract, ok := contracts[firstLine]
if !ok {
return "", "", nil, fmt.Errorf("request example lacks a transport fixture: %s", firstLine)
}
if seen[firstLine] {
return "", "", nil, fmt.Errorf("duplicate request example: %s", contract.name)
}
seen[firstLine] = true
calls = append(calls, pulseAPIRecipe{contract, block})
case strings.Contains(block, "pulse_api "):
return "", "", nil, fmt.Errorf("request example must start with the private-response helper")
case strings.Contains(block, "curl "):
return "", "", nil, fmt.Errorf("request bypasses the private-response helper")
}
}
if preparation == "" || helper == "" || len(calls) != 8 {
err = fmt.Errorf("missing credential/helper recipe or request examples (got %d)", len(calls))
if preparation == "" || helper == "" {
return "", "", nil, fmt.Errorf("missing credential preparation or private-response helper")
}
for _, contract := range pulseAPIRequestContracts() {
if !seen[contract.firstLine] {
return "", "", nil, fmt.Errorf("missing request example: %s", contract.name)
}
}
return
}
func pulseAPIReference(t *testing.T) (string, string, []string) {
func pulseAPIReferenceDocument(t *testing.T) string {
t.Helper()
doc, err := os.ReadFile(repoFile("docs", "API.md"))
if err != nil {
@@ -51,13 +104,65 @@ func pulseAPIReference(t *testing.T) (string, string, []string) {
if err != nil || string(doc) != string(mirror) {
t.Fatal("shipped API reference differs from the tested recipes")
}
prep, helper, calls, err := pulseAPIRecipes(string(doc))
return string(doc)
}
func pulseAPIReference(t *testing.T) (string, string, []pulseAPIRecipe) {
t.Helper()
prep, helper, calls, err := pulseAPIRecipes(pulseAPIReferenceDocument(t))
if err != nil {
t.Fatal(err)
}
return prep, helper, calls
}
func TestPulseAPIGuidanceRecipeCatalogueIsCompleteAndOrderIndependent(t *testing.T) {
doc := pulseAPIReferenceDocument(t)
prep, helper, calls := pulseAPIReference(t)
fence := func(block string) string { return "```bash\n" + block + "```\n" }
reordered := fence(prep) + fence(helper)
for i := len(calls) - 1; i >= 0; i-- {
reordered += fence(calls[i].command)
}
cases := []struct{ name, doc, wantError string }{
{"current", doc, ""},
{"reordered", reordered, ""},
{"missing preparation", strings.Replace(doc, prep, "", 1), "missing credential preparation"},
{"missing helper", strings.Replace(doc, helper, "", 1), "missing credential preparation"},
{"duplicate preparation", doc + fence(prep), "duplicate credential preparation"},
{"duplicate helper", doc + fence(helper), "duplicate private-response helper"},
{"unqualified request", doc + fence("pulse_api GET /api/unqualified\n"), "lacks a transport fixture"},
{"hidden request", doc + fence("# Request\npulse_api GET /api/state/summary\n"), "must start with"},
{"direct curl", doc + fence("curl --disable http://127.0.0.1:7655/api/state/summary\n"), "bypasses"},
{"changed method", strings.Replace(doc, "pulse_api GET /api/state/summary\n", "pulse_api PUT /api/state/summary\n", 1), "lacks a transport fixture"},
{"changed node input", strings.Replace(doc, `pulse_api POST /api/config/nodes < "$node_request_file"`, "pulse_api POST /api/config/nodes", 1), "lacks a transport fixture"},
}
for _, call := range calls {
cases = append(cases,
struct{ name, doc, wantError string }{"missing " + call.contract.name, strings.Replace(doc, call.command, "", 1), "missing request example"},
struct{ name, doc, wantError string }{"duplicate " + call.contract.name, doc + fence(call.command), "duplicate request example"})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, got, err := pulseAPIRecipes(tc.doc)
if tc.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("recipe catalogue error = %v, want %q", err, tc.wantError)
}
return
}
if err != nil || len(got) != len(pulseAPIRequestContracts()) {
t.Fatalf("complete catalogue rejected: %v", err)
}
for _, call := range got {
if !strings.HasPrefix(call.command, call.contract.firstLine+"\n") {
t.Fatal("request was bound to another example's transport contract")
}
}
})
}
}
func runPulseAPIRecipe(t *testing.T, script, home, bin string, extraEnv ...string) (string, int) {
t.Helper()
result, err := observeGuidanceRecipe(t, script, home, bin, guidanceRecipeLimits(20*time.Second), extraEnv...)
@@ -248,10 +353,9 @@ type apiRecipeRequest struct{ method, uri, token, contentType, body string }
func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing.T) {
_, helper, calls := pulseAPIReference(t)
paths := []string{"/api/state/summary", "/api/connections", "/api/agent/resource-capabilities/vm%3A42", "/api/actions/plan", "/api/actions/act_.../decision", "/api/actions/act_.../execute", "/api/audit/actions?resourceId=vm%3A42&limit=10", "/api/audit/actions/act_.../events"}
for index, call := range calls {
for _, call := range calls {
for _, scenario := range []string{"success", "401", "403", "redirect", "partial", "untrusted HTTPS", "trusted HTTPS"} {
t.Run(fmt.Sprintf("request-%d/%s", index, scenario), func(t *testing.T) {
t.Run(call.contract.name+"/"+scenario, func(t *testing.T) {
home, bin := t.TempDir(), t.TempDir()
dir := filepath.Join(home, ".config", "pulse")
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -315,11 +419,40 @@ func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing
t.Cleanup(server.Close)
recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1)
extra := []string{"API_ARGV=" + capture, "API_CURL=" + realCurl}
contentType, body := "", ""
var nodeInput string
switch call.contract.input {
case "heredoc":
contentType = "application/json"
_, body, _ = strings.Cut(call.command, "<<'JSON'\n")
body = strings.TrimSuffix(body, "JSON\n")
case "file":
// Fixed, non-credential body bytes test the private-file
// transport. The node guide's schema/trust specimen and
// preparation are independently covered by test_node_api_docs.py.
contentType, body = "application/json", "{\"fixture\":\"node-api-doc-fixture\"}\n"
file, err := os.CreateTemp(home, "node-request-*.json")
if err != nil {
t.Fatal(err)
}
info, err := file.Stat()
if err != nil || info.Mode().Perm() != 0600 {
_ = file.Close()
t.Fatal("node input must be private before writing")
}
_, writeErr := io.WriteString(file, body)
closeErr := file.Close()
if writeErr != nil || closeErr != nil {
t.Fatal("could not prepare the synthetic node input")
}
nodeInput = file.Name()
extra = append(extra, "node_request_file="+nodeInput)
}
if !strings.Contains(scenario, "HTTPS") {
// The local recipe must not send credentials to an ambient proxy.
extra = append(extra, "http_proxy=http://127.0.0.1:1", "ALL_PROXY=http://127.0.0.1:1")
}
output, exit := runPulseAPIRecipe(t, recipe+"\n"+call, home, bin, extra...)
output, exit := runPulseAPIRecipe(t, recipe+"\n"+call.command, home, bin, extra...)
wantExit := map[string]int{"success": 0, "401": 22, "403": 22, "redirect": 1, "partial": 18, "untrusted HTTPS": 60, "trusted HTTPS": 0}[scenario]
if exit != wantExit {
t.Fatalf("request exit %d, want %d", exit, wantExit)
@@ -328,7 +461,7 @@ func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing
if err != nil || !strings.HasPrefix(string(argv), "--disable\x00") {
t.Fatal("curl did not ignore configuration first")
}
for _, sensitive := range []string{token, "private-fixture-name", "private-fixture-event"} {
for _, sensitive := range []string{token, "private-fixture-name", "private-fixture-event", "node-api-doc-fixture"} {
if strings.Contains(output, sensitive) || strings.Contains(string(argv), sensitive) {
t.Fatal("request printed private credentials or response data")
}
@@ -349,6 +482,13 @@ func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing
if data, err := os.ReadFile(retained); err != nil || string(data) != "previous-result" {
t.Fatal("request replaced an earlier response")
}
if nodeInput != "" {
data, readErr := os.ReadFile(nodeInput)
info, statErr := os.Stat(nodeInput)
if readErr != nil || statErr != nil || string(data) != body || info.Mode().Perm() != 0600 {
t.Fatal("request changed its private node input")
}
}
if scenario == "untrusted HTTPS" {
if len(requests) != 0 {
t.Fatal("untrusted TLS peer received credentials")
@@ -358,14 +498,7 @@ func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing
t.Fatal("request was missing, redirected or retried")
}
got := <-requests
method := "GET"
contentType, body := "", ""
if index >= 3 && index <= 5 {
method, contentType = "POST", "application/json"
_, body, _ = strings.Cut(call, "<<'JSON'\n")
body = strings.TrimSuffix(body, "JSON\n")
}
if got != (apiRecipeRequest{method, paths[index], token, contentType, body}) {
if got != (apiRecipeRequest{call.contract.method, call.contract.uri, token, contentType, body}) {
t.Fatal("request method, endpoint, private header or JSON body differs from the example")
}
if data, err := os.ReadFile(responses[0]); err != nil || string(data) != response {
@@ -377,8 +510,49 @@ func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing
}
}
func TestPulseAPIGuidanceMissingNodeInputStopsBeforeCurl(t *testing.T) {
_, helper, calls := pulseAPIReference(t)
home, bin := t.TempDir(), t.TempDir()
dir := filepath.Join(home, ".config", "pulse")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: synthetic-header\n"), 0600); err != nil {
t.Fatal(err)
}
called := filepath.Join(home, "curl-called")
if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nprintf started > \"$API_CALLED\"\n"), 0700); err != nil {
t.Fatal(err)
}
for _, call := range calls {
if call.contract.name != "add-node" {
continue
}
_, exit := runPulseAPIRecipe(t, helper+"\n"+call.command, home, bin,
"node_request_file="+filepath.Join(home, "absent.json"), "API_CALLED="+called)
if _, err := os.Stat(called); exit == 0 || !os.IsNotExist(err) {
t.Fatal("missing private node input reached curl or succeeded")
}
responses, err := filepath.Glob(filepath.Join(dir, "api-response.*"))
if err != nil || len(responses) != 0 {
t.Fatal("missing node input entered the request helper")
}
return
}
t.Fatal("node request recipe is missing")
}
func TestPulseAPIGuidanceTimedOutPOSTIsNotRepeated(t *testing.T) {
_, helper, calls := pulseAPIReference(t)
var execute string
for _, call := range calls {
if call.contract.name == "execute" {
execute = call.command
}
}
if execute == "" {
t.Fatal("execution request recipe is missing")
}
home, bin := t.TempDir(), t.TempDir()
dir := filepath.Join(home, ".config", "pulse")
if err := os.MkdirAll(dir, 0700); err != nil {
@@ -406,7 +580,7 @@ func TestPulseAPIGuidanceTimedOutPOSTIsNotRepeated(t *testing.T) {
// A deliberate preparation delay exceeds the old whole-shell comparison
// once added to the actual twenty-second request. It must not shorten curl's
// limit or be mistaken for request time.
result, err := observeGuidanceRecipe(t, "sleep 12\n"+helper+"\n"+calls[5], home, bin, guidanceRecipeLimits(20*time.Second))
result, err := observeGuidanceRecipe(t, "sleep 12\n"+helper+"\n"+execute, home, bin, guidanceRecipeLimits(20*time.Second))
if err != nil {
t.Fatalf("timed-out POST observation failed: %v; %s", err, result.summary())
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
node --test "${root}/tests/integration/scripts/fixture-readiness.test.mjs"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
node --test "${root}/tests/integration/scripts/run-playwright.test.mjs"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Keep passive agent-authentication triage aligned with the real install flow.
These source/document controls do not authenticate an installed agent or
establish a reporter's cause. No credential, service or target is accessed.
"""
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/UNIFIED_AGENT.md"
def section():
text = DOC.read_text(encoding="utf-8")
marker = "### Agent lookup returns HTTP 401 or 403\n"
if marker not in text:
raise AssertionError("missing agent authentication triage")
return re.split(r"\n#{2,3} ", text.split(marker, 1)[1], 1)[0]
class AgentAuthDocsTest(unittest.TestCase):
def test_status_explanations_match_authentication_before_scoped_lookup(self):
text = " ".join(section().split())
routes = (ROOT / "internal/api/router_routes_registration.go").read_text()
self.assertIn(
'"/api/agents/agent/lookup", RequireAuth(r.config, '
'RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup)',
routes,
)
auth = (ROOT / "internal/api/auth.go").read_text()
self.assertIn('http.Error(w, "Invalid API token", http.StatusUnauthorized)', auth)
scope = (ROOT / "internal/api/apihttp/scope.go").read_text()
denial = scope.split("func RespondMissingScope(", 1)[1]
self.assertIn("w.WriteHeader(http.StatusForbidden)", denial)
self.assertIn('"error":', denial)
self.assertIn('"missing_scope"', denial)
for fact in ("authenticates the request **before**", "**HTTP 401** is an authentication rejection",
"**HTTP 403** with `missing_scope`", "`agent:report`", "**HTTP 404**",
"upstream authentication layer"):
self.assertIn(fact, text)
def test_normal_install_is_not_conflated_with_bootstrap_enrolment(self):
text = " ".join(section().split())
install = (ROOT / "frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx").read_text()
args = install.split("const getInstallerExtraArgs = () => [", 1)[1].split("];", 1)[0]
self.assertNotIn("--enroll", args)
scopes = (ROOT / "internal/api/agenttokens/install.go").read_text()
host = scopes.split("func HostScopes(", 1)[1].split("\n}", 1)[0]
self.assertIn("config.ScopeAgentReport", host)
self.assertIn("config.ScopeAgentConfigRead", host)
self.assertNotIn("ScopeAgentEnroll", host)
prompt = (ROOT / "frontend-modern/src/utils/agentInstallCommand.ts").read_text()
self.assertIn('read -r -s -p', prompt)
self.assertIn('--token-file "$token_file"', prompt)
for fact in ("silent token prompt", "does not change reporting permissions",
"It does not use `--enroll`", "bootstrap token for a runtime token",
"Do not substitute a bootstrap token", "normal host installation"):
self.assertIn(fact, text)
def test_triage_preserves_unknowns_state_and_private_credentials(self):
text = " ".join(section().split())
host = (ROOT / "internal/hostagent/agent.go").read_text()
self.assertIn("Pulse rejected this agent's API token", host)
client = (ROOT / "internal/remoteconfig/client.go").read_text()
self.assertIn('Str("component", "remote_config_client")', client)
for fact in ("bounded agent log reader", "affected agent host", "retrieval and does not establish",
"ordinary host reports were accepted", "successful-looking installer output does not prove",
"No host-report error", "inconclusive", "Do not restart, re-enrol, delete identity",
"same Pulse instance", "do not prove that the running service is using that record",
"service environments, token files and connection files private",
"Do not grant administrator scope", "disable authentication/TLS",
"manually redacted error line", "saved credentials unchanged",
"not itself a reason to reinstall or rotate every token"):
self.assertIn(fact, text)
self.assertNotIn("```", section(), "Reuse the bounded reader; do not add live credential probes")
def test_shipped_help_matches_the_canonical_guide(self):
self.assertEqual(DOC.read_bytes(),
(ROOT / "frontend-modern/public/docs/UNIFIED_AGENT.md").read_bytes())
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -203,14 +203,17 @@ class APIAuthDocsTest(unittest.TestCase):
self.assertNotRegex(bash, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)")
blocks = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
requests = [block for block in blocks if block.startswith("pulse_api ")]
self.assertEqual(len(requests), 8)
self.assertEqual(len(requests), 9)
self.assertEqual([block for block in blocks if "curl " in block], [request_helper()],
"an example must not bypass the private-response helper")
self.assertIn("curl --disable --fail-with-body ", request_helper())
for request in requests:
if request.startswith("pulse_api POST "):
self.assertIn("--data-binary @-", request_helper())
self.assertIn("<<'JSON'", request)
if request.startswith("pulse_api POST /api/config/nodes "):
self.assertEqual(request.strip(), 'pulse_api POST /api/config/nodes < "$node_request_file"')
else:
self.assertIn("<<'JSON'", request)
def test_every_documented_shell_recipe_parses(self):
for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL):
+39
View File
@@ -255,6 +255,45 @@ class E2EImageBundleTest(unittest.TestCase):
class E2EImageWorkflowTest(unittest.TestCase):
def assert_fixture_admission(self, workflow):
consumer = job_block(workflow, "e2e")
image_admission = consumer.index("- name: Admit the source-bound test images")
startup = consumer.index("- name: Start test containers")
fixture_admission = consumer.index("- name: Admit the default mock runtime")
stable = consumer.index("- name: Run stable-tier E2E suite")
probation = consumer.index("- name: Run probation-tier E2E suite")
self.assertLess(image_admission, startup)
self.assertLess(startup, fixture_admission)
self.assertLess(fixture_admission, stable)
end = consumer.index("\n - name:", fixture_admission)
admission = consumer[fixture_admission:end]
self.assertIn("npx playwright test --config=fixture-readiness.config.ts", admission)
self.assertIn('PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true"', admission)
for ignored in ("continue-on-error", "if:", "||"):
self.assertNotIn(ignored, admission)
for receipt in ("Upload Playwright report", "Upload JUnit results"):
start = consumer.index(f"- name: {receipt}\n")
self.assertLess(stable, start)
self.assertLess(start, probation)
end = consumer.index("\n - name:", start)
self.assertIn("!cancelled() && steps.stable.outcome == 'failure'", consumer[start:end])
def test_fixture_admission_cannot_skip_source_images_or_lose_completed_receipts(self):
self.assert_fixture_admission(WORKFLOW.read_text())
def test_ignored_fixture_and_late_receipt_mutants_are_rejected(self):
source = WORKFLOW.read_text()
for old, new in (
("run: npx playwright test --config=fixture-readiness.config.ts",
"run: npx playwright test --config=fixture-readiness.config.ts || true"),
(" id: fixture\n", " id: fixture\n continue-on-error: true\n"),
('PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true"', 'PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "false"'),
("!cancelled() && steps.stable.outcome == 'failure'", "failure()"),
):
with self.subTest(mutation=new):
with self.assertRaises(AssertionError):
self.assert_fixture_admission(source.replace(old, new))
def assert_workflow(self, workflow):
producer = job_block(workflow, "test-images")
self.assertIn("needs: tier-selection", producer)
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Exercise copied ICMP-help commands without systemd or privilege changes.
Synthetic command recorders check disclosure, deadlines and failure sequencing.
They do not establish native ICMP recovery, capability grants or notification
delivery. The guide deliberately requires an operator's policy/state judgment.
"""
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
HEADING = "### ICMP probe privileges\n"
PROPERTIES = "LoadState,ActiveState,User,NoNewPrivileges,AmbientCapabilities,CapabilityBoundingSet"
PRIVATE = "synthetic-private-environment-value"
def section():
text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8")
return text.split(HEADING, 1)[1].split("\n---\n", 1)[0]
def recipe(needle):
blocks = [b for b in re.findall(r"```bash\n(.*?)```", section(), re.S)
if needle in b]
if len(blocks) != 1:
raise AssertionError(f"expected one ICMP-help recipe for {needle!r}")
return blocks[0]
SYSTEMCTL = '''#!/usr/bin/env python3
import json, os, sys, time
from pathlib import Path
args = sys.argv[1:]
root = Path(os.environ['RECORD_DIR'])
with (root / 'calls.jsonl').open('a') as f:
f.write(json.dumps(args) + '\\n')
mode = os.environ.get('READ_MODE', 'loaded')
if args[0] == 'show':
if mode == 'denied':
print('Read denied', file=sys.stderr)
sys.exit(1)
if mode == 'hang':
time.sleep(30)
requested = args[-1].removeprefix('--property=').split(',')
values = {
'LoadState': 'not-found' if mode == 'missing' else 'loaded',
'ActiveState': 'active', 'User': 'pulse', 'NoNewPrivileges': 'yes',
'AmbientCapabilities': '', 'CapabilityBoundingSet': 'cap_net_raw',
}
if args[-1] == '--all' or not args[-1].startswith('--property='):
print('Environment=' + os.environ['PRIVATE_VALUE'])
for key in requested:
print(key + '=' + values.get(key, os.environ['PRIVATE_VALUE']))
elif args[0] == 'restart':
sys.exit(int(os.environ.get('RESTART_EXIT', '0')))
elif args[0] == 'is-active':
print('active')
else:
sys.exit(65)
'''
def execute(command, **overrides):
with tempfile.TemporaryDirectory(prefix="icmp docs ") as directory:
root = Path(directory)
tools = root / "tools"
tools.mkdir()
for name, body in (("systemctl", SYSTEMCTL), ("sudo", '#!/bin/sh\nexec "$@"\n')):
path = tools / name
path.write_text(body, encoding="utf-8")
path.chmod(0o700)
env = {**os.environ, "RECORD_DIR": directory, "PRIVATE_VALUE": PRIVATE,
"PATH": str(tools) + os.pathsep + os.environ["PATH"], **overrides}
result = subprocess.run(["bash", "-c", command], env=env, text=True,
capture_output=True, timeout=12)
record = root / "calls.jsonl"
calls = [json.loads(line) for line in record.read_text().splitlines()] if record.exists() else []
return result, calls
class ICMPPrivilegeDocsTest(unittest.TestCase):
def test_install_and_privilege_claims_match_existing_source(self):
text = " ".join(section().split())
installer = (ROOT / "install.sh").read_text()
for setting in ("AmbientCapabilities=CAP_NET_RAW", "CapabilityBoundingSet=CAP_NET_RAW",
"NoNewPrivileges=true"):
self.assertIn(setting, text)
self.assertIn(setting, installer)
self.assertIn("Updates keep an existing unit file untouched", installer)
for boundary in ("Updates preserve an existing service unit", "not a reliable unit repair",
"An ambient line alone cannot overcome the ceiling",
"Preserve that restriction", "Do not reset or widen",
"set file capabilities on the system-wide ping binary",
"do not run Pulse as root"):
self.assertIn(boundary, text)
self.assertNotIn("Docker installs are unaffected", text)
self.assertNotIn("it rewrites the unit", text)
def test_location_state_and_recovery_are_not_inferred(self):
text = " ".join(section().split())
for boundary in ("selected observation host", "not evidence that the target is down",
"cannot repair an agent's permissions", "inside the Pulse LXC",
"not pulse-agent.service", "failed or timed-out read is **unknown**",
"Keep deliberately inactive services inactive", "not the running process",
"monitoring and alerts are unavailable", "same location",
"not ICMP recovery or notification delivery", "not repeated restarts",
"do not use `systemctl revert`", "preserve other drop-in content",
"preserving data and configuration", "Do not use `--privileged`",
"--cap-add=ALL", "not proof that ICMP recovered"):
self.assertIn(boundary, text)
# The installer-independent probe executes on either server or agent.
probe = (ROOT / "internal/availabilityprobe/probe.go").read_text()
self.assertIn('exec.CommandContext(ctx, "ping", args...)', probe)
def test_copied_read_is_bounded_allowlisted_and_read_only(self):
command = recipe("systemctl show")
self.assertIn("timeout --signal=TERM --kill-after=1s 8s", command)
self.assertIn("--property=" + PROPERTIES, command)
self.assertNotIn("sudo", command)
for mode in ("loaded", "missing"):
with self.subTest(mode=mode):
result, calls = execute(command, READ_MODE=mode)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls, [["show", "pulse.service", "--property=" + PROPERTIES]])
self.assertNotIn(PRIVATE, result.stdout + result.stderr)
self.assertEqual(set(line.split("=", 1)[0] for line in result.stdout.splitlines()),
set(PROPERTIES.split(",")))
self.assertIn("LoadState=" + ("not-found" if mode == "missing" else "loaded"),
result.stdout)
def test_failed_or_hung_read_has_no_mutation_or_retry(self):
for mode, code in (("denied", 1), ("hang", 124)):
with self.subTest(mode=mode):
result, calls = execute(recipe("systemctl show"), READ_MODE=mode)
self.assertEqual(result.returncode, code, result.stderr)
self.assertEqual(calls, [["show", "pulse.service", "--property=" + PROPERTIES]])
self.assertNotIn(PRIVATE, result.stdout + result.stderr)
def test_copied_restart_failure_stops_before_readback(self):
command = recipe("systemctl restart")
for code in (0, 5):
with self.subTest(restart_exit=code):
result, calls = execute(command, RESTART_EXIT=str(code))
self.assertEqual(result.returncode, code, result.stderr)
expected = [["restart", "pulse.service"]]
if code == 0:
expected.append(["is-active", "pulse.service"])
self.assertEqual(calls, expected)
self.assertNotIn(PRIVATE, result.stdout + result.stderr)
self.assertEqual(result.stdout, "active\n" if code == 0 else "")
def test_copied_commands_parse_and_mirror_matches(self):
blocks = re.findall(r"```bash\n(.*?)```", section(), re.S)
self.assertEqual(len(blocks), 4)
for command in blocks:
subprocess.run(["bash", "-n", "-c", command], check=True, capture_output=True)
self.assertIn("INSTALL.md#2-bare-metal--systemd", section())
self.assertEqual((ROOT / "docs/CONFIGURATION.md").read_bytes(),
(ROOT / "frontend-modern/public/docs/CONFIGURATION.md").read_bytes())
if __name__ == "__main__":
unittest.main()
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Exercise the existing node API guide with synthetic private credentials.
All HTTP is guest-local loopback, not a platform or production probe.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
import unittest
import test_api_auth_docs as auth
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/API.md"
# A fixed, non-credential marker proves body transport and output redaction.
# Never substitute an account token, environment value or generated credential.
PLATFORM_FIXTURE_MARKER = "node-api-doc-fixture-7cd40730"
def section() -> str:
return DOC.read_text().split("## 🖥️ Nodes & Config\n", 1)[1].split("\n## ", 1)[0]
def preparation() -> str:
blocks = re.findall(r"```bash\n(.*?)```", section(), re.DOTALL)
return next(block for block in blocks if 'vi "$node_request_file"' in block)
def sample() -> dict:
add_node = section().split("### Add Node\n", 1)[1].split("\n### ", 1)[0]
blocks = re.findall(r"```json\n(.*?)```", add_node, re.DOTALL)
if len(blocks) != 1:
raise AssertionError("expected one node configuration specimen")
return json.loads(blocks[0])
def request() -> str:
blocks = re.findall(r"```bash\n(.*?)```", section(), re.DOTALL)
return next(block for block in blocks if block.startswith("pulse_api POST "))
class NodeAPIDocsTest(unittest.TestCase):
def test_specimen_uses_scoped_token_and_explicit_tls(self):
payload = sample()
self.assertEqual(payload["type"], "pve")
self.assertTrue(payload["host"].startswith("https://"))
self.assertNotIn("user", payload)
self.assertNotIn("password", payload)
self.assertNotIn("root@pam", json.dumps(payload))
self.assertEqual(payload["tokenName"], "pulse-monitor@pve!pulse-readonly")
self.assertEqual(payload["tokenValue"], "<platform-token-secret>")
self.assertIs(payload["verifySSL"], True)
schema = (ROOT / "internal/api/configapi/config_handlers.go").read_text()
schema = schema.split("type NodeConfigRequest struct {", 1)[1].split("\n}", 1)[0]
fields = set(re.findall(r'json:"([^",]+)', schema))
self.assertLessEqual(set(payload), fields, "the copied fields must be accepted by this request type")
def test_scopes_live_tests_and_connection_identity_are_explicit(self):
text = section()
routes = ("", "/test-connection", "/test-config", "/{id}", "/{id}/test", "/{id}/refresh-cluster")
for route in routes:
method = "PUT" if route == "/{id}" else "POST"
self.assertIn(f"`{method} /api/config/nodes{route}` (admin, `settings:write`)", text)
self.assertIn("`GET /api/config/nodes` (admin, `settings:read`)", text)
self.assertIn("`DELETE /api/config/nodes/{id}` (admin, `settings:write`)", text)
for boundary in ("not individual VMs or agents", "returned\nconnection `id`",
"no save", "live platform requests", "not a passive read",
"freeze/thaw", "not all\ncollection permissions",
"does not\nselect authorised nodes"):
self.assertIn(boundary, text)
self.assertNotIn("Validation Only", text)
def test_credentials_trust_and_uncertain_changes_are_separate(self):
text = section()
for boundary in ("Pulse API token", "Proxmox token", "different credentials",
"does **not** enable certificate verification",
"independent trusted channel", "not established trust",
"Do not clear an existing pin", "can\nalso try to create a token and ACLs",
"without retries", "lost response", "re-read the\nsaved inventory",
"masked credentials", "not the platform workloads or the upstream token",
"Collection and related alert coverage can stop",
"reports and shared repositories"):
self.assertIn(boundary, text)
self.assertNotRegex("\n".join(re.findall(r"```bash\n(.*?)```", text, re.DOTALL)),
r"root@pam|tokenValue|password|--insecure|--location|--retry")
def test_copied_preparation_is_private_unique_and_stops_on_failure(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
tools = home / "tools"
tools.mkdir()
calls = home / "editor-calls.jsonl"
editor = tools / "vi"
editor.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
"with Path(os.environ['EDITOR_CALLS']).open('a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n"
"Path(sys.argv[1]).write_text('{\"fixture\": true}')\n"
)
editor.chmod(0o700)
env = dict(os.environ, HOME=str(home), TMPDIR=str(home),
PATH=f"{tools}:{os.environ['PATH']}", EDITOR_CALLS=str(calls))
created = []
for _ in range(2):
result = subprocess.run(["bash", "-eu", "-c", preparation()], env=env,
capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, b"", "preparation must not print credentials")
argv = json.loads(calls.read_text().splitlines()[-1])
self.assertEqual(len(argv), 1)
path = Path(argv[0])
self.assertEqual(path.name, "node.json")
self.assertEqual(path.read_text(), '{"fixture": true}')
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700)
created.append(path)
self.assertNotEqual(*created, "a new preparation must not overwrite the previous request")
before = calls.read_bytes()
failed = subprocess.run(["bash", "-eu", "-c", preparation()],
env=dict(env, TMPDIR=str(home / "missing")),
capture_output=True, timeout=10)
self.assertNotEqual(failed.returncode, 0)
self.assertEqual(calls.read_bytes(), before, "failed preparation must not open an editor")
self.assertEqual(created[0].read_text(), '{"fixture": true}')
def test_copied_file_dispatch_is_private_bounded_and_never_retried(self):
payload = sample()
payload["tokenValue"] = PLATFORM_FIXTURE_MARKER
# Independent operation expectation: the fixture must not accept a
# different route, method, credential or body just because it was copied.
self.assertEqual(request().strip(), 'pulse_api POST /api/config/nodes < "$node_request_file"')
for status, partial in ((201, False), (401, False), (403, False),
(302, False), (500, False), (200, True)):
with self.subTest(status=status, partial=partial), tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
# mkstemp creates a unique mode-0600 file before any body is
# written, independent of the process umask. Do not repair
# initially public permissions after writing the fixture.
fd, name = tempfile.mkstemp(prefix="node-", suffix=".json", dir=home)
body = Path(name)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
self.assertEqual(stat.S_IMODE(os.fstat(stream.fileno()).st_mode), 0o600)
stream.write(json.dumps(payload))
script = f'node_request_file={json.dumps(str(body))}\n' + auth.request_helper() + "\n" + request()
with auth.private_recording_server(status, partial) as (port, calls):
result = auth.exercise_curl(self, home, "X-API-Token: " + auth.TEST_TOKEN,
port, script, private_response=True)
self.assertEqual(len(calls), 1)
path, headers, method, data = calls[0]
self.assertEqual((path, method), ("/api/config/nodes", "POST"))
self.assertEqual(headers["X-API-Token"], auth.TEST_TOKEN)
self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(json.loads(data), payload)
self.assertEqual(body.read_text(), json.dumps(payload))
argv = (home / "argv.json").read_text()
self.assertNotIn(PLATFORM_FIXTURE_MARKER, argv)
self.assertNotIn(PLATFORM_FIXTURE_MARKER.encode(), result.stdout + result.stderr)
self.assertEqual(result.returncode == 0, status == 201 and not partial)
def test_missing_request_file_sends_nothing(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
with auth.recording_server() as (port, calls):
script = f'node_request_file={json.dumps(str(home / "absent.json"))}\n'
script += auth.request_helper().replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
script += "\n" + request()
result = subprocess.run(["bash", "-eu", "-c", script], capture_output=True, timeout=10)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(calls, [], "missing private input must not fall back to another request body")
def test_all_documented_node_shell_parses(self):
for block in re.findall(r"```bash\n(.*?)```", section(), re.DOTALL):
subprocess.run(["bash", "-n", "-c", block], check=True, capture_output=True)
def test_private_response_helper_is_preserved(self):
helper = auth.request_helper()
self.assertIn('curl --disable --fail-with-body --silent --show-error', helper)
self.assertIn('--header "@$auth_file"', helper)
self.assertIn('--data-binary @-', helper)
self.assertIn('--output "$result_file"', helper)
self.assertNotIn('--retry', helper)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
import { preferredBrowserBaseURL } from './tests/runtime-defaults';
// This is admission to the existing suite, not another tier or a replacement
// for its browser assertions. Use the same authenticated readiness helper.
export default defineConfig({
testDir: './fixture-readiness',
testMatch: 'default-mock-ready.spec.ts',
outputDir: 'test-results-readiness',
workers: 1,
retries: 0,
forbidOnly: !!process.env.CI,
timeout: 360_000,
globalTimeout: 420_000,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report-readiness', open: 'never' }],
['junit', { outputFile: 'test-results-readiness/junit.xml' }],
],
use: {
...devices['Desktop Chrome'],
baseURL: preferredBrowserBaseURL(),
ignoreHTTPSErrors: ['1', 'true', 'yes', 'on'].includes(
String(process.env.PULSE_E2E_INSECURE_TLS || '').trim().toLowerCase(),
),
},
});
@@ -0,0 +1,23 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from '@playwright/test';
import { createAuthenticatedStorageState } from '../tests/helpers';
test('admit the authenticated default mock inventory and seven-day history', async ({ browser }) => {
if (!['1', 'true', 'yes', 'on'].includes(
String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || '').trim().toLowerCase(),
)) {
throw new Error('default mock admission requires PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY=true');
}
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-e2e-readiness-'));
try {
// The existing helper performs first-run authentication and the complete
// inventory/pool/disk-history checks. It also retains the usual shared
// cookie session for subsequent fixtures; do not add another login path.
await createAuthenticatedStorageState(browser, path.join(directory, 'session.json'));
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,2 @@
export function defaultMockInventoryReady(state: unknown): boolean;
export function defaultMockHistoryReady(charts: unknown): boolean;
@@ -0,0 +1,32 @@
// The shared fixture's existing admission requirements. Keep browser setup
// and the early CI probe on one policy; readiness is not application acceptance.
const hasSource = (resource, source) =>
Array.isArray(resource?.sources) && resource.sources.includes(source);
export function defaultMockInventoryReady(state) {
const resources = Array.isArray(state?.resources) ? state.resources : [];
const infrastructure = Array.isArray(state?.connectedInfrastructure)
? state.connectedInfrastructure : [];
return resources.some(r => r?.name === 'nvme-primary' && r.type === 'storage' && hasSource(r, 'vmware')) &&
resources.some(r => r?.type === 'k8s-cluster' && hasSource(r, 'kubernetes')) &&
resources.some(r => r?.name === 'tank' && hasSource(r, 'truenas')) &&
resources.some(r => ['vm', 'system-container'].includes(r?.type) && hasSource(r, 'proxmox')) &&
resources.some(r => r?.name === 'esxi-01.lab.local' && r.type === 'agent' && hasSource(r, 'vmware')) &&
resources.some(r => r?.type === 'docker-host') &&
resources.some(r => r?.type === 'pbs') &&
resources.some(r => r?.type === 'pmg') &&
infrastructure.some(entry => entry?.name === 'esxi-01.lab.local');
}
function hasDeepSeries(points) {
if (!Array.isArray(points)) return false;
const timestamps = points.map(point => Number(point?.timestamp))
.filter(Number.isFinite).sort((left, right) => left - right);
return timestamps.length >= 2 &&
timestamps[timestamps.length - 1] - timestamps[0] > 5 * 24 * 60 * 60 * 1000;
}
export function defaultMockHistoryReady(charts) {
return Object.values(charts?.pools ?? {}).some(pool => hasDeepSeries(pool?.used)) &&
Object.values(charts?.disks ?? {}).some(disk => hasDeepSeries(disk?.temperature));
}
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { defaultMockInventoryReady, defaultMockHistoryReady } from './default-mock-readiness.mjs';
const integrationRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const root = path.resolve(integrationRoot, '..', '..');
const source = file => fs.readFileSync(path.join(integrationRoot, file), 'utf8');
const workflow = fs.readFileSync(process.env.PULSE_READINESS_TEST_WORKFLOW ||
path.join(root, '.github/workflows/test-e2e.yml'), 'utf8');
const steps = workflow.split('\n e2e:\n')[1].split('\n agent-registration:\n')[0];
const step = name => steps.split(` - name: ${name}\n`)[1]?.split('\n - name:')[0];
const readyInventory = () => ({
connectedInfrastructure: [{ name: 'esxi-01.lab.local' }],
resources: [
{ name: 'nvme-primary', type: 'storage', sources: ['vmware'] },
{ type: 'k8s-cluster', sources: ['kubernetes'] },
{ name: 'tank', sources: ['truenas'] },
{ type: 'vm', sources: ['proxmox'] },
{ name: 'esxi-01.lab.local', type: 'agent', sources: ['vmware'] },
{ type: 'docker-host' }, { type: 'pbs' }, { type: 'pmg' },
],
});
const series = [{ timestamp: 1 }, { timestamp: 7 * 24 * 60 * 60 * 1000 }];
const readyHistory = () => ({ pools: { tank: { used: series } }, disks: { sdc: { temperature: series } } });
test('complete existing inventory passes, including the container alternative', () => {
const state = readyInventory();
assert.equal(defaultMockInventoryReady(state), true);
state.resources[3].type = 'system-container';
assert.equal(defaultMockInventoryReady(state), true);
});
test('each required resource and connected-host identity is necessary', () => {
for (let i = 0; i < readyInventory().resources.length; i++) {
const state = readyInventory();
state.resources.splice(i, 1);
assert.equal(defaultMockInventoryReady(state), false, `missing resource ${i}`);
}
const state = readyInventory();
state.connectedInfrastructure = [{ name: 'another-host' }];
assert.equal(defaultMockInventoryReady(state), false);
});
test('platform attribution and fixed names cannot borrow another source', () => {
for (let i = 0; i < 5; i++) {
const state = readyInventory();
state.resources[i].sources = ['unrelated'];
assert.equal(defaultMockInventoryReady(state), false);
}
for (const i of [0, 2, 4]) {
const state = readyInventory();
state.resources[i].name = 'unrelated';
assert.equal(defaultMockInventoryReady(state), false);
}
});
test('unknown or malformed inventory cannot pass admission', () => {
for (const state of [null, undefined, {}, { resources: {} }, { resources: [null] }]) {
assert.equal(defaultMockInventoryReady(state), false);
}
const state = readyInventory();
state.resources[0].sources = 'vmware';
assert.equal(defaultMockInventoryReady(state), false);
});
test('both pool and physical-disk history are required at the original depth', () => {
assert.equal(defaultMockHistoryReady(readyHistory()), true);
for (const charts of [null, undefined, {}, { pools: readyHistory().pools }, { disks: readyHistory().disks }]) {
assert.equal(defaultMockHistoryReady(charts), false);
}
for (const points of [[], [{ timestamp: 1 }], [{ timestamp: 'bad' }, { timestamp: 700_000_000 }],
[{ timestamp: 0 }, { timestamp: 5 * 24 * 60 * 60 * 1000 }]]) {
for (const kind of ['pool', 'disk']) {
const charts = readyHistory();
if (kind === 'pool') charts.pools.tank.used = points;
else charts.disks.sdc.temperature = points;
assert.equal(defaultMockHistoryReady(charts), false, `${kind}: ${JSON.stringify(points)}`);
}
}
const charts = readyHistory();
charts.pools.tank.used = [...series].reverse();
assert.equal(defaultMockHistoryReady(charts), true);
});
async function invokeAdmission(flag, check) {
let invocation;
// Execute the committed spec body with controlled browser/check callbacks,
// not a copy of its orchestration. Real Playwright discovery is also checked
// by the owning exact-source command.
const body = source('fixture-readiness/default-mock-ready.spec.ts').replace(/^import .*;\n/gm, '');
runInNewContext(body, {
fs, os, path, Error,
process: { env: { PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: flag } },
test: (_name, callback) => { invocation = callback({ browser: 'owned-browser' }); },
createAuthenticatedStorageState: check,
});
await invocation;
}
test('the real admission spec calls shared authentication once and clears temporary state', async () => {
let calls = 0;
let filename;
await invokeAdmission('true', async (browser, statePath) => {
calls++;
filename = statePath;
assert.equal(browser, 'owned-browser');
fs.writeFileSync(statePath, 'controlled session');
});
assert.equal(calls, 1);
assert.equal(fs.existsSync(path.dirname(filename)), false);
});
test('original inventory/history failure escapes unchanged and clears temporary state', async () => {
for (const message of ['default mock inventory should be complete before browser fixtures start',
'default mock history should cover the seven-day Core E2E chart window before browser fixtures start']) {
let calls = 0;
let filename;
const failure = new Error(message);
await assert.rejects(invokeAdmission('true', async (_browser, statePath) => {
calls++;
filename = statePath;
fs.writeFileSync(statePath, 'partial controlled session');
throw failure;
}), err => err === failure);
assert.equal(calls, 1);
assert.equal(fs.existsSync(path.dirname(filename)), false);
}
});
test('missing readiness opt-in fails instead of admitting unchecked fixtures', async () => {
for (const flag of [undefined, '', 'false']) {
let called = false;
await assert.rejects(invokeAdmission(flag, async () => { called = true; }),
/requires PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY=true/);
assert.equal(called, false);
}
});
test('dedicated discovery cannot retry, absorb or mix admission with either tier', () => {
const config = source('fixture-readiness.config.ts');
for (const required of ["testDir: './fixture-readiness'", "testMatch: 'default-mock-ready.spec.ts'",
'workers: 1', 'retries: 0', 'timeout: 360_000', 'globalTimeout: 420_000',
'test-results-readiness/junit.xml', 'playwright-report-readiness']) {
assert.ok(config.includes(required), required);
}
assert.ok(!config.includes('PULSE_E2E_TIER'));
const helper = source('tests/helpers.ts');
assert.match(helper, /defaultMockInventoryReady\(await response\.json\(\)\)/);
assert.match(helper, /defaultMockHistoryReady\(await response\.json\(\)\)/);
assert.match(helper, /timeout: 120_000/);
assert.match(helper, /timeout: 180_000/);
});
test('workflow admission precedes both tiers without an ignored failure', () => {
const admission = step('Admit the default mock runtime');
assert.ok(admission, 'missing fixture admission');
assert.match(admission, /PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true"/);
assert.match(admission, /timeout-minutes: 8/);
assert.match(admission, /working-directory: tests\/integration/);
assert.doesNotMatch(admission, /continue-on-error|\n if:|\|\|/);
assert.ok(steps.indexOf('- name: Admit the default mock runtime') < steps.indexOf('- name: Run stable-tier E2E suite'));
});
test('actual workflow shell admits healthy fixtures and stops broken ones before suite work', () => {
const command = step('Admit the default mock runtime')?.match(/\n run: (.+)/)?.[1] || '';
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-admission-command-'));
try {
fs.writeFileSync(path.join(directory, 'npx'), '#!/bin/sh\n[ "$*" = "playwright test --config=fixture-readiness.config.ts" ] || exit 9\n' +
'echo "controlled default mock readiness result"\nexit "$ADMISSION_EXIT"\n', { mode: 0o700 });
for (const status of [0, 1]) {
const marker = path.join(directory, `suite-${status}`);
const result = spawnSync('bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c',
`${command}\nprintf suite-started > "${marker}"`], {
cwd: integrationRoot, encoding: 'utf8', env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, ADMISSION_EXIT: String(status) },
});
assert.equal(result.status, status, result.stdout + result.stderr);
assert.equal(fs.existsSync(marker), status === 0);
}
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
});
test('completed stable failure receipts are uploaded before probation can be cancelled', () => {
for (const name of ['Upload Playwright report', 'Upload JUnit results']) {
assert.ok(steps.indexOf(`- name: ${name}`) < steps.indexOf('- name: Run probation-tier E2E suite'), name);
assert.match(step(name), /!cancelled\(\) && steps\.stable\.outcome == 'failure'/);
}
assert.match(step('Upload failed fixture admission'), /!cancelled\(\) && steps\.fixture\.outcome == 'failure'/);
assert.match(step('Run probation-tier E2E suite (non-gating)'), /continue-on-error: true/);
assert.ok(steps.includes('timeout-minutes: 60'));
});
@@ -200,8 +200,9 @@ test('shared browser readiness waits for the bounded mock history contract', asy
assert.match(compose, /PULSE_MOCK_TRENDS_SEED_DURATION=\$\{PULSE_MOCK_TRENDS_SEED_DURATION:-168h\}/);
assert.match(helpers, /"\/api\/storage-charts\?range=10080"/);
assert.match(helpers, /hasDeepSeries\(pool\.used\)/);
assert.match(helpers, /hasDeepSeries\(disk\.temperature\)/);
assert.match(helpers, /defaultMockInventoryReady\(await response\.json\(\)\)/);
assert.match(helpers, /defaultMockHistoryReady\(await response\.json\(\)\)/);
assert.match(helpers, /default-mock-readiness\.mjs/);
assert.match(helpers, /default mock history should cover the seven-day Core E2E chart window/);
});
+45 -18
View File
@@ -46,17 +46,23 @@ async function writeManagedVerifyLock(env = process.env) {
}
const lockPath = managedVerifyLockPath(env);
const contents = `pid=${process.pid}\ncreated_at=${new Date().toISOString()}\nrun_id=${String(env.PULSE_E2E_RUN_ID || '').trim()}\n`;
await fs.mkdir(path.dirname(lockPath), { recursive: true });
await fs.writeFile(
lockPath,
`pid=${process.pid}\ncreated_at=${new Date().toISOString()}\nrun_id=${String(env.PULSE_E2E_RUN_ID || '').trim()}\n`,
'utf8',
);
await fs.writeFile(lockPath, contents, 'utf8');
return { lockPath, contents };
}
async function clearManagedVerifyLock(env = process.env) {
const lockPath = managedVerifyLockPath(env);
await fs.rm(lockPath, { force: true });
async function clearManagedVerifyLock(lock) {
if (!lock) return;
try {
// Non-hot-dev runs never acquired this handoff. A later owner may also
// have replaced it while teardown ran; do not remove that owner's lock.
if (await fs.readFile(lock.lockPath, 'utf8') === lock.contents) {
await fs.unlink(lock.lockPath);
}
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
const run = (command, args, options = {}) =>
@@ -68,21 +74,42 @@ const run = (command, args, options = {}) =>
let exitCode = 0;
const childEnv = buildRunScopedEnv();
let verifyLock;
let setupStarted = false;
function reportFailure(phase, err) {
console.error(`[integration] ${phase} failed:`, err?.message || err);
if (exitCode === 0) exitCode = 1;
}
try {
await writeManagedVerifyLock(childEnv);
const pretestCode = await run(nodeCmd, ['./scripts/pretest.mjs'], { env: childEnv });
if (pretestCode !== 0) {
process.exit(pretestCode);
verifyLock = await writeManagedVerifyLock(childEnv);
setupStarted = true;
exitCode = await run(nodeCmd, ['./scripts/pretest.mjs'], { env: childEnv });
// A failed setup may already have started a managed runtime. Keep its
// failure, skip Playwright, and still reach the existing teardown below.
if (exitCode === 0) {
exitCode = await run(npxCmd, ['playwright', 'test', ...playwrightArgs], { env: childEnv });
}
exitCode = await run(npxCmd, ['playwright', 'test', ...playwrightArgs], { env: childEnv });
} catch (err) {
reportFailure('setup or test launch', err);
} finally {
const posttestCode = await run(nodeCmd, ['./scripts/posttest.mjs'], { env: childEnv });
if (exitCode === 0 && posttestCode !== 0) {
exitCode = posttestCode;
try {
// If lock preparation failed, this invocation never started a runtime
// and must not stop a pre-existing one through posttest.
if (setupStarted) {
const posttestCode = await run(nodeCmd, ['./scripts/posttest.mjs'], { env: childEnv });
if (exitCode === 0) exitCode = posttestCode;
}
} catch (err) {
reportFailure('teardown launch', err);
} finally {
try {
await clearManagedVerifyLock(verifyLock);
} catch (err) {
reportFailure('verification lock cleanup', err);
}
}
await clearManagedVerifyLock(childEnv);
}
process.exit(exitCode);
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import test from 'node:test';
const execute = promisify(execFile);
// An exact-parent control can select its saved source without changing the
// production runner or replacing the assertions with source-string checks.
const runnerSource = await fs.readFile(
process.env.PULSE_RUNNER_TEST_SOURCE || new URL('./run-playwright.mjs', import.meta.url),
'utf8',
);
const phaseScript = `
import fs from 'node:fs';
import path from 'node:path';
const root = process.env.FIXTURE_ROOT;
const phase = process.env.FIXTURE_PHASE;
fs.appendFileSync(path.join(root, 'calls.jsonl'), JSON.stringify({ phase,
runId: process.env.PULSE_E2E_RUN_ID,
state: process.env.PULSE_E2E_RUNTIME_STATE_PATH,
lockPath: process.env.HOT_DEV_VERIFY_LOCK_FILE,
lock: fs.existsSync(process.env.HOT_DEV_VERIFY_LOCK_FILE)
? (fs.statSync(process.env.HOT_DEV_VERIFY_LOCK_FILE).isFile()
? fs.readFileSync(process.env.HOT_DEV_VERIFY_LOCK_FILE, 'utf8') : 'not-a-file') : null,
args: process.argv.slice(2),
}) + '\\n');
`;
async function fixture(t, options = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-npm-cleanup-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'scripts'));
await fs.mkdir(path.join(root, 'bin'));
await fs.writeFile(path.join(root, 'scripts/run-playwright.mjs'), runnerSource);
await fs.writeFile(path.join(root, 'scripts/pretest.mjs'), `
process.env.FIXTURE_PHASE = 'pretest';
${phaseScript}
fs.writeFileSync(path.join(root, 'partial-runtime'), 'started');
process.exit(${options.pretest ?? 0});
`);
await fs.writeFile(path.join(root, 'scripts/posttest.mjs'), `
process.env.FIXTURE_PHASE = 'posttest';
${phaseScript}
fs.rmSync(path.join(root, 'partial-runtime'), { force: true });
${options.replaceLock ? "fs.writeFileSync(process.env.HOT_DEV_VERIFY_LOCK_FILE, 'later-owner\\n');" : ''}
process.exit(${options.posttest ?? 0});
`);
if (!options.missingNpx) {
await fs.writeFile(path.join(root, 'bin/npx'), `#!${process.execPath}
process.env.FIXTURE_PHASE = 'playwright';
${phaseScript}
process.exit(${options.playwright ?? 0});
`, { mode: 0o755 });
}
const lockPath = path.join(root, 'verify.lock');
if (options.foreignLock) await fs.writeFile(lockPath, options.foreignLock);
if (options.lockDirectory) await fs.mkdir(lockPath);
const env = {
...process.env,
PATH: path.join(root, 'bin'),
FIXTURE_ROOT: root,
PULSE_E2E_USE_HOT_DEV: options.hotDev === false ? 'false' : 'true',
PULSE_E2E_RUN_ID: 'fixture-invocation',
PULSE_E2E_RUNTIME_STATE_PATH: path.join(root, 'runtime-state.json'),
HOT_DEV_VERIFY_LOCK_FILE: lockPath,
};
let result;
try {
const output = await execute(process.execPath,
['./scripts/run-playwright.mjs', '--project=chromium'],
{ cwd: root, env, timeout: 5000 });
result = { code: 0, ...output };
} catch (err) {
// A killed/timed-out fixture is an error, never the expected nonzero exit.
assert.equal(err.killed, false, err.message);
assert.equal(err.signal, null, err.message);
assert.equal(typeof err.code, 'number', err.message);
result = { code: err.code, stdout: err.stdout, stderr: err.stderr };
}
const calls = await fs.readFile(path.join(root, 'calls.jsonl'), 'utf8')
.then(raw => raw.trim().split('\n').map(line => JSON.parse(line)))
.catch(err => { if (err.code === 'ENOENT') return []; throw err; });
for (const call of calls) {
assert.equal(call.runId, env.PULSE_E2E_RUN_ID);
assert.equal(call.state, env.PULSE_E2E_RUNTIME_STATE_PATH);
assert.equal(call.lockPath, env.HOT_DEV_VERIFY_LOCK_FILE);
}
return { root, lockPath, calls, result };
}
async function assertAbsent(file) {
await assert.rejects(fs.stat(file), { code: 'ENOENT' });
}
test('failed setup tears down its partial runtime and lock without launching tests', async t => {
const f = await fixture(t, { pretest: 17 });
assert.equal(f.result.code, 17);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'posttest']);
assert.match(f.calls[0].lock, /\nrun_id=fixture-invocation\n$/);
assert.equal(f.calls[1].lock, f.calls[0].lock, 'handoff stays held through teardown');
await assertAbsent(path.join(f.root, 'partial-runtime'));
await assertAbsent(f.lockPath);
});
test('failed setup remains failed when teardown also fails', async t => {
const f = await fixture(t, { pretest: 17, posttest: 23 });
assert.equal(f.result.code, 17);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'posttest']);
await assertAbsent(f.lockPath);
});
for (const [playwright, posttest, expected] of [[0, 0, 0], [19, 0, 19], [0, 23, 23], [19, 23, 19]]) {
test(`test exit ${playwright}, teardown exit ${posttest} retain exit ${expected} and cleanup`, async t => {
const f = await fixture(t, { playwright, posttest });
assert.equal(f.result.code, expected);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'playwright', 'posttest']);
assert.deepEqual(f.calls[1].args, ['playwright', 'test', '--project=chromium']);
for (const call of f.calls) assert.equal(call.lock, f.calls[0].lock);
await assertAbsent(path.join(f.root, 'partial-runtime'));
await assertAbsent(f.lockPath);
});
}
test('non-hot-dev invocation preserves a verification lock it never acquired', async t => {
const foreignLock = `pid=${process.pid}\nrun_id=another-live-invocation\n`;
const f = await fixture(t, { hotDev: false, foreignLock });
assert.equal(f.result.code, 0);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'playwright', 'posttest']);
assert.equal(await fs.readFile(f.lockPath, 'utf8'), foreignLock);
});
test('teardown preserves a handoff replaced by a later owner', async t => {
const f = await fixture(t, { replaceLock: true });
assert.equal(f.result.code, 0);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'playwright', 'posttest']);
assert.equal(await fs.readFile(f.lockPath, 'utf8'), 'later-owner\n');
});
test('failed test launch still tears down setup and the acquired lock', async t => {
const f = await fixture(t, { missingNpx: true });
assert.equal(f.result.code, 1);
assert.match(f.result.stderr, /ENOENT/);
assert.deepEqual(f.calls.map(c => c.phase), ['pretest', 'posttest']);
await assertAbsent(path.join(f.root, 'partial-runtime'));
await assertAbsent(f.lockPath);
});
test('failed lock preparation cannot stop a runtime this invocation never started', async t => {
const f = await fixture(t, { lockDirectory: true });
assert.equal(f.result.code, 1);
assert.deepEqual(f.calls, []);
assert.equal((await fs.stat(f.lockPath)).isDirectory(), true);
});
+7 -87
View File
@@ -20,6 +20,11 @@ import {
runtimeStatePath,
} from "./runtime-defaults";
import {
defaultMockInventoryReady,
defaultMockHistoryReady,
} from "../scripts/default-mock-readiness.mjs";
const helpersDir = path.dirname(fileURLToPath(import.meta.url));
const runtimePrimaryAPIToken = (): string => {
@@ -1140,26 +1145,6 @@ async function storageStateHasLiveSession(
}
}
type E2ERuntimeStateResource = {
name?: string;
sources?: string[];
type?: string;
};
type E2ERuntimeState = {
connectedInfrastructure?: Array<{ name?: string }>;
resources?: E2ERuntimeStateResource[];
};
type E2EMetricPoint = {
timestamp?: number;
};
type E2EStorageCharts = {
pools?: Record<string, { used?: E2EMetricPoint[] }>;
disks?: Record<string, { temperature?: E2EMetricPoint[] }>;
};
const requiresDefaultMockRuntimeReadiness = (): boolean =>
["1", "true", "yes", "on"].includes(
String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "")
@@ -1167,11 +1152,6 @@ const requiresDefaultMockRuntimeReadiness = (): boolean =>
.toLowerCase(),
);
const resourceHasSource = (
resource: E2ERuntimeStateResource,
source: string,
): boolean => resource.sources?.includes(source) === true;
export async function waitForDefaultMockRuntimeReady(page: Page): Promise<void> {
if (!requiresDefaultMockRuntimeReadiness()) {
return;
@@ -1185,48 +1165,7 @@ export async function waitForDefaultMockRuntimeReady(page: Page): Promise<void>
return false;
}
const state = (await response.json()) as E2ERuntimeState;
const resources = Array.isArray(state.resources) ? state.resources : [];
const infrastructure = Array.isArray(state.connectedInfrastructure)
? state.connectedInfrastructure
: [];
return (
resources.some(
(resource) =>
resource.name === "nvme-primary" &&
resource.type === "storage" &&
resourceHasSource(resource, "vmware"),
) &&
resources.some(
(resource) =>
resource.type === "k8s-cluster" &&
resourceHasSource(resource, "kubernetes"),
) &&
resources.some(
(resource) =>
resource.name === "tank" &&
resourceHasSource(resource, "truenas"),
) &&
resources.some(
(resource) =>
(resource.type === "vm" ||
resource.type === "system-container") &&
resourceHasSource(resource, "proxmox"),
) &&
resources.some(
(resource) =>
resource.name === "esxi-01.lab.local" &&
resource.type === "agent" &&
resourceHasSource(resource, "vmware"),
) &&
resources.some((resource) => resource.type === "docker-host") &&
resources.some((resource) => resource.type === "pbs") &&
resources.some((resource) => resource.type === "pmg") &&
infrastructure.some(
(entry) => entry.name === "esxi-01.lab.local",
)
);
return defaultMockInventoryReady(await response.json());
},
{
message:
@@ -1237,17 +1176,6 @@ export async function waitForDefaultMockRuntimeReady(page: Page): Promise<void>
)
.toBe(true);
const hasDeepSeries = (points: E2EMetricPoint[] | undefined): boolean => {
const timestamps = (points ?? [])
.map((point) => Number(point.timestamp))
.filter(Number.isFinite)
.sort((left, right) => left - right);
if (timestamps.length < 2) {
return false;
}
return timestamps[timestamps.length - 1] - timestamps[0] > 5 * 24 * 60 * 60 * 1000;
};
// Inventory becomes available before the monitor has finished building its
// historical mock timeline. Do not advertise the shared browser fixture as
// ready until the deepest Core E2E chart window is queryable for both pools
@@ -1264,15 +1192,7 @@ export async function waitForDefaultMockRuntimeReady(page: Page): Promise<void>
return false;
}
const charts = (await response.json()) as E2EStorageCharts;
return (
Object.values(charts.pools ?? {}).some((pool) =>
hasDeepSeries(pool.used),
) &&
Object.values(charts.disks ?? {}).some((disk) =>
hasDeepSeries(disk.temperature),
)
);
return defaultMockHistoryReady(await response.json());
},
{
message: