Keep guest readings truthful, expire memory history and improve safe setup guidance (#2456)

## What changed and why

- Guest filesystem History distinguishes paused/retained readings from
current observations. Incomplete readings stay unavailable rather than
appearing as healthy empty disks; measured zero and known capacity
remain useful. Related to #2439 and #1723.
- Installation help removes the minimal root-server service recipe,
explains the existing hardened installer and distinguishes a missing
unit from a loaded root service. Removal guidance preserves persistent
data by default, explains temporary container storage and Helm claim
ownership, requires a consistent private full-state backup and separates
disabling the service from destructive erasure.
- TrueNAS setup help distinguishes a successful system-information
connection test from collected inventory/metrics and preserves polling
evidence. Passive VM preflight rejects malformed configuration before
concluding that an agent setting or backup lock is absent. Related to
#2382 and #2439.
- The native backup evidence checker requires observed post-task
filesystem and memory reads, not merely fresh-looking timestamps.
- Add an exact-published-RC browser update/recovery workflow with
authenticated archives, reviewed control identity, a dedicated narrow CI
registration, self-only Serve and terminal refusal handling. It is a
qualification route, not a qualification result.

The documentation and filesystem changes address unsafe implications in
existing surfaces; no new product route or feature is added. The browser
route supports the required release update/recovery journey.

## Included source

Range:
`13df84e9038be8017110105186b44f626154a3f6..c0ceff669774cec9f3b71f0bade244133f9949bb`.

All previously reviewed identities and their ancestry are retained:

- TrueNAS candidates `1c999113` / `59a27e2c`, integration `22f16541`,
additive published-main merge `a6b83db5`, passive preflight `adcd0ddf`
(the original PR head).
- Installation candidates `a6959e57` / `01adf338`, integration
`6cf691ec`; data-preserving removal candidate `fa6282b7`, integration
`0f3575bd`.
- Filesystem/History candidates `28bb0fb5` / `81b07e8a`, compositions
`ba8b9fcd` / `5d1b03c1`, integration `53cfdf60`.
- Browser-route candidate sequence `fdf46457`, `619133aa`, `729b2cf7`,
`f0ff1934`, `73b790b8`, `d855d322`, `30ca9681`, `a847b05e`, `2fb4bf9e`,
integration `b42563d9`.
- Backup-resumption checker `1920db3c`, integration `acace4d4`.
- New additive publication correction `c0ceff66` normalizes only
Markdown whitespace and capitalization in the TrueNAS safety assertions.
Every required warning and diagnostic field remains checked;
documentation/runtime content is unchanged by this correction.

## Validation and remaining limits

Recorded reviewed source evidence includes full TypeScript checking, 407
filesystem/UI regressions across fifteen relevant families, production
embedded build and unchanged bundle budgets (`source-bnh7z6xi`); 58
removal-help controls/types/build/budgets (`source-f_l3trzu`); inspected
desktop Chromium and phone-emulated WebKit help and filesystem states;
57 connected browser-route controls/workflow trust (`source-agx1sbr6`);
and nineteen focused backup-checker methods. These remain evidence at
their original source bindings. Publication comparisons confirm
unchanged validated runtime, removal guidance and qualification-route
bytes; the final browser guard covers all four changed frontend runtime
files with their in-range receipts.

Exact final-source offline smoke receipt `source-ljvxb1yx` at
`c0ceff669774cec9f3b71f0bade244133f9949bb` ran 59 families: 57 passed,
two failed; exit 1 with complete output and confirmed guest stop. All
changed smoke families, including the corrected seven TrueNAS tests and
new browser-route controls, passed. The failures concern unchanged
historical Git fingerprints (unavailable objects in the disposable
source input) and an extra synthetic npm-audit invocation. The same
unchanged tests passed in the original hosted PR run and all 21 pass in
a confined lightweight native-Git comparison. The npm observation
remains unresolved with Delivery; the complete VM suite is **not**
reported as a pass or replaced by the comparison.

Publication also verifies provenance, clean committed source, upstream
and starting-reviewed-tip ancestry, matching documentation mirrors and
whitespace. Removing either affected safety warning still fails the
corrected assertion. Sufficient completed frontend/browser proofs were
not repeated just because their changes share this batch.

The original PR's completed run `37175962472` failed required script
smoke on capitalization/line wrapping, now corrected additively. Its
advisory benchmark reported
`HandleMetricsHistory_MemoryFallback/all-metrics` +12.58% (p=0.019, ten
samples). No Go or benchmark source differs between that original head
and its base; this is not a causal diagnosis or a passing measurement.
The adverse result remains with Delivery for independent disposition.
The old head has finished checks and cannot land independently because
of the required smoke failure, so this proposal advances once to include
the coherent reviewed batch and correction, rather than restarting
still-running CI.

Exact-head review and protected checks remain required. Earlier
unexecuted readiness attempts are not reconstructed passes. This source
does not establish native thaw, TrueNAS collection, installed recovery,
a containing release or deployment. The browser route still needs its
dedicated registration/ACL prerequisite and activated dispatcher before
native use; the earlier HTTP401 remains adverse and unreplayed. Release
qualification, soak and promotion remain separate.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)

## Additive follow-on batch and final publication check

The completed exact-head Build and Test run `37179468966` cannot land:
its required rest-1 job rejects the browser workflow's Node `24.20.0`
selector under the existing governed `24` parity contract. Correction
`903dc08d23d4278103950e0df6ed5f7b8fb97760` selects the same `24` line as
other workflows and adds a focused trust regression; no parity
assertion, pinned action, browser tooling, privilege or release gate is
weakened. The old head is terminal, not still validating, so the
proposal advances once with this repair and the coherent reviewed
follow-on source.

Final range:
`13df84e9038be8017110105186b44f626154a3f6..903dc08d23d4278103950e0df6ed5f7b8fb97760`.
Additional accepted identities, preserved unchanged:

- Complete VM-configuration response boundary `227f4f7e` / composition
`f7eeabc4`, integrated by `de081296`: do not admit guest reads from an
incomplete unlocked JSON prefix. Related to #2439.
- Token help `74c35227` / `383be305`, integration `630f45a2`: match
seven least-privilege presets, distinguish prompt exposed-token
revocation from planned rotation, and explain kiosk bearer-link and
per-tab limits.
- Guest workload evidence `23235502`, integration `0e41d8d2`: preserve
existing disk-deferral, guest-agent and lock facts through API and
canonical snapshots, clear obsolete omitted facts only after an explicit
new native outcome, and withdraw unavailable numeric bars while
preserving labelled prior and measured-zero readings. Related to #2439
and #1723.
- Used-memory History retention `0ea87307`, integration `768952fd`:
expire byte samples alongside existing series without deleting
still-live byte-only resource entries. This is not an attribution of
reported process memory growth.

Aggregate reviewed evidence adds `source-hctbddnb` for complete-response
parent/final race controls; `source-n8_u1xm9` for 37 token-help
controls, types/build/budgets, with fixture-only syntax
`source-5ws674f3` and six inspected desktop/phone-emulated captures;
`source-c8k64dn9` for types, 761 tests in eighteen relevant files,
lint/audits/build/budgets, forty browser transitions and six inspected
captures; and `source-0qcd2wj7` for adverse parent omissions, final
retention and connected History readers under race/vet/affected-package
build. These retain their original source bindings. Publication
comparisons confirm final changed Web runtime and Core Go bytes match
those tested candidates, and the final browser guard covers all seven
changed frontend runtime files through their own-parent in-range
receipts. Sufficient compiler, build and visual proofs were not repeated
solely for batching.

Exact final-source offline receipt **`source-1_rh272s`** at
`903dc08d23d4278103950e0df6ed5f7b8fb97760` passes the previously failing
Node parity test under race, selected
guest-agent/backup/History/memory/metric-window families in Proxmox and
monitoring under race, 18 browser-packet/Tailnet controls and 43
workflow-trust controls. Exit 0, complete 11,065-byte output and
completed guest stop; this is focused source proof, not a full-suite or
release qualification claim.

The same old-head run also reports advisory `ReportMarshal_Parallel-4`
+23.25% (p=0.009, ten samples, candidate spread 19%). At that old
proposal head, Go/dependency/build-and-test/benchmark source is
unchanged from its base. This establishes no changed benchmark source,
not a passing measurement or a cause. It remains adverse alongside the
earlier +12.58% History result under the existing Delivery disposition
request. No unchanged retry, threshold relaxation or release clearance
is claimed. The newer retention repair changes Go source, so it is not
covered by that unchanged-source comparison. New exact-head required
checks and independent review must finish before landing.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-04 06:24:04 +00:00
committed by GitHub
72 changed files with 5860 additions and 248 deletions
+22
View File
@@ -371,6 +371,28 @@ non-mutating wrapper that verifies the current committed stable target. All
stable demo writes require an exact stable tag and activation marker and run
from `release-convergence.yml` under the global customer-promotion lease.
## Published-RC browser qualification registration
`qualify-browser-update-release.yml` must **not** reuse the demo binding above.
It uses the separate repository secrets **TS_CI_DEPLOY_OAUTH_CLIENT_ID** and
**TS_CI_DEPLOY_OAUTH_SECRET** for a dedicated business-tailnet OAuth client with
only **Auth Keys write**, restricted to **tag:ci-deploy**. These names must not
alias/copy the `tag:infra` demo client or a full-access administrative client.
The Tailnet/credential owner verifies that client scope and tag restriction when
configuring the pair; source inspection and the node's observed tag do not prove
OAuth scope. No secret value belongs in source, logs or receipts.
The workflow fails before joining when either dedicated secret is unavailable;
there is no demo-credential, auth-key or caller-input fallback. It checks the
actual running node's **Self.Tags == ["tag:ci-deploy"]** and its own business
DNS name before starting Serve. Only its own `127.0.0.1:7655` is served over
HTTPS, with no peer selection, SSH, routes or private demo/production target.
This journey needs no expansion of CI access to private services. Cleanup resets
that new Serve route even if route setup fails after the successful join; the
pinned Tailscale action and fresh hosted VM own node teardown. A configured
binding, source tests or a joined node is not native update/recovery acceptance.
## Helm CI
**File**: `helm-ci.yml`
@@ -0,0 +1,177 @@
name: Qualify Published RC Browser Update
run-name: Browser update ${{ inputs.tag }} at ${{ inputs.expected_workflow_sha }}
# Control runs only from reviewed public main. No source build, production
# target, publishing credential, old stopped-target retry or arbitrary URL.
on:
workflow_dispatch:
inputs:
tag:
description: 'Exact published immutable vX.Y.Z-rc.N'
required: true
type: string
expected_workflow_sha:
description: 'Exact reviewed main control SHA'
required: true
type: string
permissions:
contents: read
concurrency:
group: browser-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
journey:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Checkout reviewed control without credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Bind control and exact published release pair
id: pair
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
EXPECTED_CONTROL: ${{ inputs.expected_workflow_sha }}
run: |
set -euo pipefail
test "$GITHUB_REPOSITORY" = rcourtman/Pulse
test "$GITHUB_REF" = refs/heads/main
test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"
test "$GITHUB_SHA" = "$EXPECTED_CONTROL"
test "$(git rev-parse HEAD)" = "$EXPECTED_CONTROL"
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
mkdir -m 0700 "$RUNNER_TEMP/browser-release"
gh api repos/rcourtman/Pulse/releases/latest > "$RUNNER_TEMP/browser-release/baseline.json"
gh api "repos/rcourtman/Pulse/releases/tags/$TAG" > "$RUNNER_TEMP/browser-release/candidate.json"
from=$(jq -er '.tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/browser-release/baseline.json")
peel() {
local object type sha
object=$(gh api "repos/rcourtman/Pulse/git/ref/tags/$1")
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
if [[ "$type" == tag ]]; then
object=$(gh api "repos/rcourtman/Pulse/git/tags/$sha")
test "$(jq -er '.tag' <<<"$object")" = "$1"
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
fi
test "$type" = commit
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
printf '%s' "$sha"
}
from_commit=$(peel "$from")
to_commit=$(peel "$TAG")
python3 scripts/release_browser_packet.py \
--baseline "$RUNNER_TEMP/browser-release/baseline.json" \
--candidate "$RUNNER_TEMP/browser-release/candidate.json" \
--from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \
--output "$RUNNER_TEMP/browser-release/packet.json"
python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")'
python3 scripts/write_github_output.py from "$from"
python3 scripts/write_github_output.py from_commit "$from_commit"
python3 scripts/write_github_output.py to_commit "$to_commit"
- name: Authenticate both release archives before execution
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
FROM_COMMIT: ${{ steps.pair.outputs.from_commit }}
TO_COMMIT: ${{ steps.pair.outputs.to_commit }}
run: |
set -euo pipefail
root="$RUNNER_TEMP/browser-release"
key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
test -n "$key"
printf 'pulse-installer %s\n' "$key" > "$root/allowed_signers"
for tag in "$FROM" "$TAG"; do
mkdir -m 0700 "$root/$tag"
name="pulse-$tag-linux-amd64.tar.gz"
for file in "$name" "$name.sshsig"; do
curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \
"https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file"
done
ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \
-s "$root/$tag/$name.sshsig" < "$root/$tag/$name"
done
python3 scripts/release_browser_packet.py --baseline "$root/baseline.json" --candidate "$root/candidate.json" \
--from-commit "$FROM_COMMIT" --to-commit "$TO_COMMIT" --tag "$TAG" \
--archives "$root" --output "$root/packet.json"
- name: Prepare pinned browser without lifecycle hooks
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Prepare Chromium and native tools
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers
run: |
set -euo pipefail
cd tests/integration
npm ci --ignore-scripts
npx --no-install playwright install --with-deps chromium
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends jq curl openssh-client
- name: Require the dedicated narrow CI registration binding
env:
CI_CLIENT_ID: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
CI_CLIENT_SECRET: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
run: |
set -euo pipefail
if [[ -z "$CI_CLIENT_ID" || -z "$CI_CLIENT_SECRET" ]]; then
echo 'Dedicated tag:ci-deploy OAuth binding is not configured; no demo credential fallback is permitted.' >&2
exit 2
fi
- name: Register a fresh ephemeral narrow CI node
id: tailnet
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
tags: tag:ci-deploy
version: '1.94.2'
- name: Serve only this disposable install
id: serve
run: |
set -euo pipefail
# Validate the actual Self tag before Serve. Never select a peer or
# fall back to the demo client's broader tag:infra identity.
origin=$(tailscale status --json | python3 scripts/release_browser_tailnet.py)
tailscale serve --bg --https=443 http://127.0.0.1:7655
python3 scripts/write_github_output.py origin "$origin"
- name: Exercise native browser update and documented recovery
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
ORIGIN: ${{ steps.serve.outputs.origin }}
run: |
set -euo pipefail
sudo env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse GITHUB_REF=refs/heads/main \
PULSE_REHEARSAL_WORKDIR="$RUNNER_TEMP/browser-release/journey" \
PULSE_REHEARSAL_BROWSER_ORIGIN="$ORIGIN" PULSE_REHEARSAL_NODE="$(command -v node)" \
PULSE_REHEARSAL_PACKET_MANIFEST="$RUNNER_TEMP/browser-release/packet.json" \
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
- name: Close ephemeral Serve route
if: always() && steps.tailnet.outcome == 'success'
run: tailscale serve reset
- name: Retain bounded identity and terminal receipts, never credentials
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-release-${{ github.run_id }}
path: |
${{ runner.temp }}/browser-release/control.json
${{ runner.temp }}/browser-release/packet.json
${{ runner.temp }}/browser-release/journey/state/browser-upgrade.json
${{ runner.temp }}/browser-release/journey/state/browser-recovery.json
${{ runner.temp }}/browser-release/journey/state/browser-cleanup.json
${{ runner.temp }}/browser-release/journey/state/lifecycle-result.json
${{ runner.temp }}/browser-release/journey/state/installer.json
${{ runner.temp }}/browser-release/journey/state/browser-*.png
${{ runner.temp }}/browser-release/journey/state/installed-*.json
${{ runner.temp }}/browser-release/journey/state/settings.*.json
${{ runner.temp }}/browser-release/journey/state/auto-updates.*.tsv
if-no-files-found: error
retention-days: 7
+76 -31
View File
@@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \
## 🔑 API Tokens
API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**.
API tokens provide scoped, revocable access to Pulse. Manage them in
**Settings → API Access**, in the **Security** group. An account without token
management permission can review the inventory but cannot create or revoke tokens.
The token shown during first-run setup is the primary automation API token for
that Pulse instance. It is separate from your web login password and is meant
for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens
are shown once; later token rows show only identifying hints such as prefix,
suffix, label, scopes, and last-used metadata.
that Pulse instance, not your web login password. Do not reuse a full-access
setup token for every consumer. Give each agent, script, integration or display
only the scopes it needs, with a name that identifies its use. Tokens are shown
once; later rows show hints and metadata, not a recoverable secret. Store the
secret privately before dismissing it.
Revoking a token is safe for Pulse itself, but it immediately breaks any agent,
script, kiosk, or integration still using that token. When a consumer needs to
stay online, create and install a replacement token first, then revoke the old
one. An agent whose token has been revoked stops authenticating until it is
reinstalled or reconfigured with a valid token.
### Replace or revoke a token
Revoking a token stops consumers still using it from authenticating. For a
planned rotation, create a least-privilege replacement, install it privately
in each affected consumer and verify a fresh authenticated result before
revoking the old token. Last-used metadata alone does not account for every
consumer. Do not reinstall an agent or delete its saved identity just to
replace a credential; use its private token-file or managed configuration.
**If a token or kiosk link has been exposed, revoke it promptly**, even if that
interrupts monitoring or a display. Replace it through the same private setup
path. Do not leave a leaked token active while arranging a gradual rotation.
For a server-address change without credential exposure, follow
[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead.
Never put a token or session cookie in a diagnostic command, screenshot or
issue thread. For scripted API access, use the
[private header-file procedure](API.md#api-token-recommended), keep TLS
verification enabled and do not share verbose or trace output.
### Token Scopes
@@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token.
| ------- | ------------- |
| `*` (Full access) | All permissions (legacy, not recommended) |
| `monitoring:read` | View dashboards, metrics, alerts |
| `monitoring:write` | Acknowledge/silence alerts |
| `monitoring:write` | Acknowledge, silence and clear alerts |
| `docker:report` | Docker / Podman agent telemetry submission |
| `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) |
| `kubernetes:report` | Kubernetes agent telemetry submission |
| `kubernetes:manage` | Kubernetes cluster management |
| `agent:report` | Agent host telemetry submission |
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Manage registered agents (unlink/delete/config) |
| `agent:exec` | Establish agent command WebSocket connections |
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration |
| `settings:write` | Modify configuration, manage tokens and trigger updates |
| `audit:read` | Read audit events, verification results, summaries, and exports |
### Presets
The UI offers quick presets for common use cases:
Use **New token**, enter a name under **Create token**, select a **Quick preset**
and choose **Generate**. The current presets are:
| Preset | Scopes | Use Case |
| -------- | -------- | ---------- |
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup |
| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays |
| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration |
| **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) |
| **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions |
| **Settings read** | `settings:read` | Read-only config access |
| **Settings admin** | `settings:read`, `settings:write` | Full config access |
| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports |
**Reporting is not remote execution.** The Agent preset does not grant
`agent:manage` or `agent:exec`; Docker / Podman report does not grant
`docker:manage`. Add lifecycle or action authority only for the operation you
intend, not to fix a missing reading. A settings-read token can read sensitive
configuration and diagnostics; it is not a public-display credential. See
[agent security](AGENT_SECURITY.md) for the separate collector and action-runner
requirements.
If **Patrol external agent** is offered, its scopes come from the current Patrol
requirements. Use the displayed set rather than guessing or adding **Full access**.
The separate **Full access** choice grants the legacy `*` wildcard, not a
least-privilege preset. Hiding controls does not reduce a token's permissions.
### Kiosk Mode
For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues:
For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only
`monitoring:read`. Never use **Full access**, a settings token or your
administrator's logged-in browser profile on an unattended display.
1. Go to **Settings → Security → API Tokens**
2. Click **New token** and select the **Kiosk / Dashboard** preset
3. Copy the generated token
4. Access Pulse via URL with token:
```text
https://your-pulse-url/?token=YOUR_TOKEN_HERE
```
1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**.
2. Use **New token**, name the display, select **Kiosk / Monitoring** and
choose **Generate**. Store the secret privately.
3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers
**Copy Link** for this monitoring-only token. Transfer that link privately
to the display; do not put it in shared bookmarks, screenshots or reports.
4. Check the display using the link in a separate browser profile with no
administrator session. Confirm it shows the intended monitoring data and
does not offer settings or write actions. A hidden control alone is not
proof of restricted access.
**Kiosk tokens:**
- Grant read-only dashboard access (`monitoring:read` scope)
- Hide the Settings tab automatically
- Work without cookies (token in URL)
- Can be revoked anytime from the UI
The link is a **bearer credential**: anyone with it can read the permitted
infrastructure data until the token expires or is revoked. The `kiosk=1`
option only hides navigation and filters; it does not authenticate a browser
or change the token's scopes.
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
Pulse removes the token from the page URL after loading and keeps it in that
tab's session storage. **The initial request already carried the token**;
HTTPS and the cleaned address bar do not remove copies from proxy/server logs,
browser history or the clipboard. Do not rely on this session surviving a
closed tab, browser restart or cleared site data. Keep the original link private
if the display needs it again, and clear clipboard copies after setup.
If the display or link is lost or exposed, revoke its dedicated token in
**API Access** and create a replacement. Revoking a display token should not
require rotating unrelated agents' credentials.
---
+138 -39
View File
@@ -193,40 +193,51 @@ sudo bash install.sh --version "${PULSE_VERSION}"
> **Note**: This installs the Pulse server. Use the `/install.sh` endpoint from **Settings → Infrastructure → Install on a host** for installing or upgrading `pulse-agent` on monitored hosts.
<details>
<summary><strong>Manual systemd install (advanced)</strong></summary>
<summary><strong>Manual or custom systemd services (advanced)</strong></summary>
For a **new Pulse server**, use the signed installer above rather than copying
one binary and writing a minimal service unit. The installer creates the
`pulse` service account, prepares the release tree and data ownership, and
installs the server service and update assets together. Its server unit uses
`User=pulse`, `Group=pulse`, `NoNewPrivileges=true`, `PrivateTmp=true`,
`ProtectSystem=strict` and `ProtectHome=true`, with writes limited to the
installation and data directories. Pulse's server does not need a root service;
the separate host **agent** has different privilege requirements (see
[Agent Security](AGENT_SECURITY.md)).
For an **existing manual or custom installation**, first inspect its effective
service settings locally. This read-only command does not print environment
values or credentials:
```bash
# Download and extract the architecture-specific tarball from GitHub Releases:
# https://github.com/rcourtman/Pulse/releases
# e.g.
# curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/pulse-${PULSE_VERSION}-linux-amd64.tar.gz"
# tar -xzf "pulse-${PULSE_VERSION}-linux-amd64.tar.gz"
# The extracted tree contains ./bin/pulse plus ./bin/pulse-agent-* and ./scripts/.
sudo install -m 0755 bin/pulse /usr/local/bin/pulse
# Create systemd service
sudo tee /etc/systemd/system/pulse.service > /dev/null << 'EOF'
[Unit]
Description=Pulse Monitoring
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/pulse
Restart=always
RestartSec=10
Environment=PULSE_DATA_DIR=/etc/pulse
[Install]
WantedBy=multi-user.target
EOF
# Start service
sudo mkdir -p /etc/pulse
sudo systemctl daemon-reload
sudo systemctl enable --now pulse
systemctl show pulse.service \
--property=LoadState \
--property=User \
--property=Group \
--property=NoNewPrivileges \
--property=PrivateTmp \
--property=ProtectSystem \
--property=ProtectHome
```
Use your actual server unit name if it differs; older installations may use
`pulse-backend.service`. Interpret these settings only when
`LoadState=loaded`; `LoadState=not-found` means the selected unit is missing,
not that a root service is running. For a loaded service, an empty `User=`
means systemd runs it as root. Missing hardening is not repaired just by
downloading a new binary:
**the installer preserves existing service units during updates**, including
custom units.
Do not overwrite a working unit, change only its service user, or reinstall over
an existing data directory to match these settings. Before a migration, follow
[the recovery guide](RECOVERY.md), preserve the data directory and its encryption
key, and record the active data path and deployment-managed configuration
privately. Check that the intended service account can access that state and
that required integrations work with the chosen sandbox. Do not loosen data
permissions, remove authentication or share a full unit/environment dump as a
shortcut.
</details>
---
@@ -338,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit.
## 🗑️ Uninstall
**Docker**:
Removing or stopping the Pulse server also stops monitoring and alert delivery.
This is not an update, rollback or password-reset procedure. Keep persistent
data by default; deleting it erases configuration, history, credentials and
the keys needed to decrypt that installation's data.
Before removing anything, identify the actual service, container or Helm
release and **every effective data path** privately. Keep a consistent,
private [full-state backup](MIGRATION.md#full-state-recovery), including the
matching encryption key and deployment configuration. A configuration export
alone is not a full backup. Let any in-progress Pulse update finish before
removing its installation. Do not post backups, environment files or full
container inspections in an issue.
### Docker and Compose: retain the data mount
The commands below assume `/data` is on a persistent named volume or bind
mount, as in this guide's examples. **Do not use them for state stored only in
the container's writable layer or temporary storage until it has a consistent
backup.** A container created with `--rm` can also delete anonymous volumes
when stopped. Container removal is not a backup.
For the `docker run` example, stop the container normally before removing it:
```bash
docker rm -f pulse && docker volume rm pulse_data
docker stop pulse
docker rm pulse
```
**Kubernetes**:
For Compose, run this from the **existing** project, using its actual Pulse
service name:
```bash
helm uninstall pulse -n pulse
docker compose stop pulse
docker compose rm pulse
```
**Systemd**:
These commands do not request volume deletion. Keep the named volume or bind
directory, original image/runtime and deployment settings; a reinstall must
reattach the **same** data mount. Compose normally prefixes volume names with
its project name, so creating a new project or an empty `pulse_data` volume
can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run
volume pruning as part of a data-preserving removal.
### Kubernetes: check claim ownership before uninstalling
Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse
chart creates a PersistentVolumeClaim without a keep policy; Helm removal can
delete that claim, and the storage reclaim policy can delete its backing data.
`persistence.existingClaim` refers to a separately managed claim, whose
lifecycle must be checked separately. With `persistence.enabled=false`, the
chart uses temporary `emptyDir` storage, lost when the pod is removed.
After verifying persistent storage and its backup, you can stop the default
deployment without uninstalling the chart:
```bash
sudo systemctl disable --now pulse
sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse
sudo systemctl daemon-reload
kubectl scale deployment pulse \
--namespace pulse \
--replicas=0
```
Use the actual deployment and namespace. Record the previous replica count
and suspend any controller that would recreate pods. Before permanently
uninstalling, verify the live claim's ownership, retention and reclaim policy
and test recovery from the private backup; do not delete a PVC or namespace
as a troubleshooting step.
### Systemd / Proxmox LXC: disable without erasing data
Identify the active service and any updater first; legacy installs can use
`pulse-backend`, and custom installs can have other names. In an LXC, run
these steps **inside the Pulse container**, not on the Proxmox host.
For the default signed installation, disable its update timer **if present**:
```bash
sudo systemctl disable --now \
pulse-update.timer
```
Then stop and disable the server:
```bash
sudo systemctl disable --now \
pulse.service
```
Disable other deployment-managed updaters through their owner too. These
commands leave the binary, service account, units and data in place; they
disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom
data directory), its keys and authentication sources together. Do not remove
the service account while retained files still need its ownership.
**Complete removal is a separate, destructive choice.** The signed server
installer's `--uninstall` deletes its configuration/data directory without a
keep-data prompt; it is not a data-preserving alternative. Use it only after
checking the effective install/config paths and a tested private backup, when
you intend that erasure. Do not delete `/bin/update` unless you have verified
it belongs to Pulse; community-scripts containers can use a different helper.
Removing the server does not remove agents on monitored hosts. Use each
agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or
[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures),
so it does not keep retrying the absent server.
+19 -10
View File
@@ -9,8 +9,13 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR
are listed under **Platform connections**.
3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the
username that owns the key.
4. Click **Test Connection** → **Save**.
5. Data appears within one configured polling cycle (60 seconds by default).
4. Click **Test Connection**, then **Save**. The test reads system information
on a separate connection; it does not validate inventory or metric collection.
5. Leave the saved connection enabled and allow at least one configured polling
interval (60 seconds by default). Check that the appliance and expected
inventory appear. If they do not, follow the [polling checks](#stale-truenas-data)
without repeatedly testing or restarting; an elapsed interval is not proof
that a poll completed.
## Creating a TrueNAS API Key
@@ -155,9 +160,11 @@ its trusted HTTPS origin without disabling certificate verification.
### No data appearing after adding connection
- Allow one configured polling cycle (60 seconds by default), not a fixed
30-second wait. Check that the saved connection is enabled.
- **Test Connection** checks authentication and the selected transport, not
successful collection of every metric. The legacy REST diagnostic is
expected for recognized CORE 13 systems; it is not itself a connection error.
- **Test Connection** reads system information on a separate connection. Pools,
datasets, disks and alerts can still fail during ordinary polling, even when
the test succeeds. A successful test also does not establish live CPU, memory
or History readings. The legacy REST diagnostic is expected for recognized
CORE 13 systems; it is not itself a connection error.
- Inspect a bounded local log excerpt for TrueNAS-related errors:
```bash
journalctl -u pulse -n 100 --no-pager | grep -i truenas
@@ -236,11 +243,13 @@ collection step.
| No timestamps advance, or no completed attempt is recorded | Check that the connection and integration are enabled, then preserve the bounded local error above. This is not proof of an invalid key. |
| Reconnects or TrueNAS sign-ins increase while `observed.collectedAt` advances | Session turnover alone does not establish failed authentication or stopped polling. Record the timing and any existing close/error message separately from the data freshness. |
**Test Connection** checks connectivity and authentication, not a complete
poll. Testing a saved connection can update `lastAttemptAt` and `lastSuccessAt`
and reset its failure count without refreshing `observed.collectedAt`. Record
any manual test and its time separately; a successful test is not evidence
that missing data or stopped polling has recovered.
**Test Connection** is not a complete poll. Some older builds, including Pulse
6.4.1 and 6.4.5, also let a saved-connection test update `lastAttemptAt` and
`lastSuccessAt` and reset its failure count without refreshing
`observed.collectedAt`. Record any manual test and its time separately, and
use the inventory observation time rather than the test time to check freshness.
A successful test is not evidence that missing data or stopped polling has
recovered.
An `app.stats` error such as **Apps are not available** concerns Apps
collection, not necessarily the key. Check the existing Apps status in
@@ -6360,7 +6360,7 @@ defaulted and nothing newer is published, the pair is the previous stable to
the latest stable. An explicit pair must be published and move forward, so a
same-version or missing-target run fails instead of passing.
`scripts/release_lifecycle_rehearsal.sh` runs every phase inside the same
`scripts/release_lifecycle_rehearsal.sh` defaults to running every phase inside the same
digest-pinned `jrei/systemd-debian:12` container as the install smoke.
1. Install FROM with that release's own `install.sh --version`, after the
@@ -6419,6 +6419,103 @@ passed all four phases. An earlier run correctly failed phase 1 on a new failed
unit (`systemd-hostnamed`), which led to the container-only list above. The
hosted Docker run is not yet claimed.
### Exact published-RC Tailscale browser acceptance
`qualify-browser-update-release.yml` uses the lifecycle harness's separate
`--hosted-browser` mode on a **fresh public hosted Ubuntu 24.04 VM**. The shadow
container mode above remains unchanged in purpose. A real native install is
required for browser updates: Docker detection stays truthful, with no marker
removal, mock updater, TLS bypass or `PULSE_ALLOW_DOCKER_UPDATES` override.
Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and
`GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable
stable and the requested published immutable RC. Peeled commit, release/asset
IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH
signatures verify before execution. Archives are bounded to512MiB (current
stable/RC archives are about265MiB), signatures to16KiB, the fixed regular
server binary to1GiB and expanded inventory to2GiB/4096 entries. Streaming
hashes avoid allocating an entire archive; anonymous downloads have the same
archive-size ceiling. Setup-action caches are explicitly disabled in this
CI-credential-bearing job, and derived command-file outputs use the reviewed
collision-safe writer. Each installed binary hash must match its
signed archive, rather than just a reported version. No rebuilt main binary,
frozen-product insertion, candidate re-cut or stable promotion is performed.
A pinned Tailscale action registers an ephemeral project CI node using only
`tag:ci-deploy` and the dedicated `TS_CI_DEPLOY_OAUTH_CLIENT_ID` /
`TS_CI_DEPLOY_OAUTH_SECRET` binding. Its OAuth client has only Auth Keys write,
restricted to that tag; it must not alias the `tag:infra` demo or full-access
administrative identity. The credential owner verifies that configured scope;
source and Self observations are not proof of OAuth/ACL authority. Missing
dedicated registration configuration stops before join, with no credential,
auth-key or caller-input fallback. `release_browser_tailnet.py` rejects anything
except a running, online Self node with exactly that one tag and its own valid
business DNS name before Serve or origin output. No peers or raw status enter
receipts. Serve exposes only its own loopback Pulse install through that derived
`tawny-powan.ts.net` HTTPS identity; no private-service ACL expansion is needed.
No caller-selected endpoint, peer or previous stopped target is used. Only the
fixed prerequisite/registration steps receive the dedicated OAuth pair; acceptance
commands receive a scrubbed environment with no provider, production or
publishing credential. State is synthetic, disabled resources and throwaway
admin/API authentication, with the existing telemetry opt-out.
The real Chromium UI signs in once, chooses Preview, checks the actual feed's
exact RC/archive, and confirms one Install Update. The observer requires real
EventSource open/progress, exact served version, healthy status, authenticated
readiness and progress-modal completion; wrong selection, silent progress,
rejected apply or a stuck modal fails. API interception and fabricated version
or progress are not acceptance. HTTP401/403 on main-frame navigation, login,
or any same-origin API/SSE request started after successful login stops browser
access immediately and closes the context to prevent UI/SSE reconnects. The
receipt records only bounded endpoint classes, numeric status and the fixed
journey step, never the URL, response body or credentials. Anonymous pre-login
auth probes are not authenticated refusals; their request-start identity remains
anonymous even if a response arrives after login. Version/health denial is not
a restart transport gap and is never polled again. Recovery requires a readable
terminal upgrade receipt explicitly recording `access_refused:false`; missing,
malformed or incomplete evidence also stops before another auth file or browser
process. No alternate identity, recovery login after refusal or apply replay is
used.
The same harness checks systemd, the seeded settings/encrypted stores and the
original auto-update/timer intent after forward update and documented CLI
`/bin/update --version FROM` recovery. Browser authentication/health readback
then checks the restored baseline. This is CLI recovery plus browser readback,
**not** the separate history-based UI rollback action. Cleanup stops the
throwaway service/timer, records its result and fails the process on failure;
the workflow resets the new Serve route after any successful join, including
partial route setup failure, and its hosted VM is disposed of.
Bounded control, signed packet, installer/binary, phase, browser, cleanup and
redacted settings receipts are retained for seven days. Successful screenshots
contain only synthetic state. Auth files, bootstrap/API/node tokens, encrypted
file manifests and raw installer/updater/service logs are not artifacts.
Delivery must reconcile terminal logs and all receipts with the exact RC;
a workflow conclusion alone is insufficient. Existing qualification, soak,
release-steward judgment and publication boundaries remain unchanged. The
previous target's undiagnosed HTTP401 is not diagnosed or cleared by this mode.
Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
release/asset/binary identity rejection, worker-mode exclusion, scrubbed command
environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py`
executes the recovery-access stop before another browser process can start.
`scripts/tests/test_release_browser_tailnet.py` executes the actual prerequisite
and Serve shell with fixed offline node observations: absent dedicated
configuration cannot borrow the demo credential, broad/extra/missing tags and
foreign/unsafe Self identities cannot start Serve or write an origin. Its binding
controls reject broad demo secret reuse and caller-selected registration inputs.
`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real
observer against offline Chromium success/recovery, anonymous auth probes,
restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus
navigation (before/after login), login, initial/readiness version, health,
authenticated API, check/apply, SSE and protected-read HTTP401/403. Each browser
upgrade receipt is then passed to the actual shell recovery guard with a fixed
local process sentinel, proving denied access prevents later auth preparation
and browser launch while non-refused recovery remains available. These are observer
and tooling proofs, **not native Tailscale/update/recovery acceptance**. That
requires reviewed publication, infrastructure activation and a terminal hosted
collection through the fixed Delivery dispatcher.
### Release-line dependency advisory watch (1 October 2026)
On 30 September and 1 October 2026 `GHSA-q2hr-2g5m-vwhr` (`brace-expansion`)
@@ -20,6 +20,24 @@
## Purpose
### Filesystem capacity is not measured usage
The existing filesystem list and guest Overview require finite positive capacity
and finite non-negative measured used bytes before displaying a percentage or
utilization bar. Missing used bytes, invalid numeric fields, the negative
unknown-usage sentinel and an overflowing ratio remain unavailable, not a healthy
zero. Known capacity is still useful as `?/capacity`; filesystem identity and
type remain visible. An explicitly measured zero stays `0%`, and omitted redundant
percentage metadata does not discard valid bytes. Valid over-capacity readings
retain their percentage and severity while the visual fill stays capped at 100%.
No percentage or free-byte field is used to invent an absent used-byte reading.
`filesystemUsage.evidence.test.tsx` covers the model and both production consumers,
including same-filesystem known → unavailable → measured-zero replacement.
`browser-tests/filesystem-usage-evidence.cjs` covers these rendered boundaries on
desktop Chromium and touch Chromium/WebKit. These synthetic observations are
not proof of appliance collection, backup thaw, installed recovery or delivery.
### Guest disk deferral and retained observations
The existing workload row, disk breakdown tooltip and guest Overview use
@@ -69,6 +87,27 @@ The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
shipped caller. Its empty-state title is not evidence of readable touch guidance;
the shipped guest Overview supplies the readable pre-sample explanation.
Guest History applies the same provenance to its live legend fallback. A VM
filesystem reason withdraws the `current` disk fallback; only a `prev-` reason
with a valid retained value supplies a separately labelled `last known` value.
Explicit telemetry unavailability and unknown/non-finite values stay unavailable,
not zero. The readable fixed-reason notice remains beside the utilization group
even when stored disk observations exist. Its wording identifies a live read,
not the date or provenance of those stored points. Legend assistive descriptions
reference that notice. CPU, memory and I/O evidence remain independent.
Deferred live evidence never supplies a chart point, timestamp or dated
inspection value. Keyboard/pointer inspection continues to show only the
selected stored observations, and denied History withdraws the whole chart
including its deferred evidence. Fresh same-VM evidence clears the notice;
lock clearance without a new read does not. `GuestDrawer.test.tsx`,
`GuestDrawerHistory.disk-provenance.test.tsx` and `GuestDrawerHistory.sparse.test.tsx`
cover the model, actual drawer plumbing, shared fallback precedence, retained
zero, pre-sample status, dated inspection, access denial and resumption.
`browser-tests/guest-history-provenance.cjs` uses the production full drawer
and History API client at desktop and touch widths with synthetic responses.
These checks do not establish native guest thaw, collection or release availability.
### Completed guest backup evidence versus current activity
The guest Overview keeps the last completed backup age and its existing
@@ -17,6 +17,20 @@
## Purpose
### Used-memory history retention
Periodic in-memory History cleanup applies the existing retention window and
backing-array release policy to raw `memoryused` bytes as well as `memory`
percentages, for guests and nodes. A live byte series alone keeps its resource
entry; an expired byte series cannot remain pinned by ongoing CPU or other
observations. Missing current memory does not refresh its last-known samples.
The existing poll recorder, chart readers and metric-window provider retain
in-window byte values and their original timestamps through cleanup, then
accept fresh memory on resumption. SQL retention, sampling limits, memory
source selection and wire formats are unchanged. The History cleanup tests
cover every supported series, byte-array reclamation and connected readers;
these controls do not attribute a reporter's native process-memory growth.
### Podman zero-percentage fallback — issue #2447
One-shot compatibility stats with `cpu:0` do not suppress advancing cumulative
@@ -44,6 +58,15 @@ backup), missing/malformed config or an unverifiable lock defers the command.
Known locks in node inventory, cluster resources or detailed status suppress
polling enrichment even when status fails and recent agent evidence exists.
The VM config body must finish within the existing response-size and request
bounds and contain exactly one complete JSON value. An unlocked JSON prefix
does not establish lock clearance: a short Content-Length body, stalled suffix,
extra JSON/garbage or chunked body exceeding 8 MiB yields `lock-unverified` both
before dispatch and after a completed guest command. Post-command verification
holds per-guest admission until body completion; its failure discards the
payload. Complete responses at the limit, including trailing whitespace, remain
valid. Ordinary PVE CPU/I/O observations remain independent of this deferral.
Admission is held through the complete response, not just headers. A transport
failure, timeout, wrong-command-ID response or incomplete body defers every
command for that guest for a minute; callers neither immediately retry nor
@@ -63,6 +86,12 @@ resource lock with failed status → node fallback → unlocked resumption throu
the real client/builders and History. The client controls cover every read,
unknown lock state, diagnostic overlap, aliases/source/VM isolation, in-flight
lock changes, incomplete bodies, cooldown and absence of failover replay.
`TestGuestAgentConfigResponseMustBeComplete` covers every guest read before and
after dispatch and subsequent healthy resumption; the stalled-prefix and
post-config-body controls pin completion and cross-client admission.
`TestUnverifiedVMConfigPreservesStatusCounterObservations` and the monitoring
lifecycle pin live counter presence and rejection of cached disk/memory History
refresh during an incomplete lock read, not just a decoder error.
These reproduce missing Pulse safety controls, **not** the reported native
PVE/QGA command-ID collision or successful backup thaw. HTTP lock checks cannot
atomically reserve PVE's serial channel against a backup beginning afterwards,
@@ -77,7 +106,13 @@ provide an independent guest filesystem writer (file/directory fsync and
synthetic readback over non-QGA stdout) and a fail-closed checker for native
overlap, truthful deferred readings/History and post-task writes/resumption.
An OK task without fresh writes on every covered filesystem cannot pass those
record checks. Local tests are not native freeze/thaw evidence; supplied
record checks. Resumed memory/disk History also needs successful native
`file-read`/`get-fsinfo` dispatches newly started after task completion and its
clock uncertainty, and completed by the resumed readback within the recorded
clock allowance. Old in-flight results, metadata-only reads, fresh timestamps
alone and later polling cannot substitute for those reads. The checker tests
record consistency, not independent payload-to-command provenance.
Local tests are not native freeze/thaw evidence; supplied
platform/artifact provenance and mount completeness still need independent
review. The tools add no runtime interface, recovery action or release gate.
@@ -15,6 +15,51 @@
## Purpose
### Workload projection preserves native guest-read evidence
`useWorkloads` forwards the existing Proxmox disk-status reason, guest-agent
state/expected flag and VM operation lock through both its resource-API and
owned canonical-snapshot paths. Numeric retained metrics cannot erase those
read-state facts. `useGuestRowState` also withdraws bars/current-value labels
for non-retained deferrals or unavailable canonical telemetry, rather than
reviving a richer stale number. Same-identity read-state changes participate in the existing
row signature even when numeric values are unchanged; unrelated rows retain
their identity. No extra resource read, timer, workload command or graph is added.
Full and fast canonical facet merges share the same bounded optional-field
clearing: a native VM facet with positive VMID, runtime state and explicit
guest-agent outcome supersedes its omitted disk-status reason, lock and
expected flag. Other richer fields and partial/legacy omission remain retained.
`useWorkloads.test.ts` and `resourceStateAdapters.test.ts` verify forwarding,
clearing, false/zero values, unchanged peer-site rows and full/fast equivalence.
`browser-tests/workload-read-evidence.cjs` composes the real hook, canonical
merges, row, Overview and History with synthetic inputs. This is no native
QGA/thaw, installed recovery, fleet-performance or release-availability claim.
### Unavailable filesystem values share the existing disk model
The filesystem list and guest Overview use the same nullable measured-byte ratio
for text, severity and progress. Per-disk validation is constant work within the
existing linear presentation pass; it adds no reads, queries, timers, remounts or
resource-identity changes. Unavailable values stay neutral rather than passing a
fabricated zero into threshold classification. Mounted filesystem evidence tests
and the production browser fixture verify replacement and rendering, not a new
latency, fleet-performance or native-collection claim.
### Guest History live filesystem provenance
The guest drawer derives deferred filesystem context from its current snapshot,
separately from current CPU, memory and I/O. A retained `prev-` value may populate
only the labelled last-known legend, never the current fallback or chart geometry.
The added presentation input does not create a polling timer, trigger a guest
read, or change History target/range ownership. Stable-target context updates
reuse the mounted chart and existing query; the drawer's pre-existing target
recomputation on guest-snapshot changes is unchanged. Mounted regressions in
`GuestDrawer.test.tsx` and `GuestDrawerHistory.disk-provenance.test.tsx` verify
the actual fallback/resumption path and request counts. This is presentation
proof, not a native CPU improvement, QGA/thaw result or delivery acceptance.
### Guest filesystem provenance preserves dense row geometry
Visible retained/unavailable filesystem cues must fit the existing dense row
@@ -23,6 +23,37 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Native VM read outcomes survive browser projection and recovery
The existing Proxmox guest read-state facet remains intact at the WorkloadGuest
boundary: diskStatusReason, guestAgentStatus, guestAgentExpected and lock are
forwarded from the resource API or the platform's owned canonical snapshot.
A retained numeric disk reading does not become current merely by passing
through that adapter, and an operation lock is not silently discarded.
Rows withdraw numeric bars/sparkline labels for a non-retained deferral or
explicitly unavailable canonical telemetry, even if the merged resource still
carries old numeric values. Valid `prev-` and measured-zero readings remain.
`GuestRow.test.tsx` verifies this with numeric stale props, not only sentinel data.
Optional native VM fields are omitted on the wire when empty/false. When a new
facet identifies a positive VMID and explicitly reports runtime state and a
guest-agent outcome, omitted diskStatusReason/lock/guestAgentExpected replace
the previous values rather than keeping an obsolete deferral or backup lock.
This applies to the canonical and compatibility provider facets and the
validated fast-delta path. Partial or legacy facets without that combination
retain previous evidence; no generic omission-based clearing is introduced.
Other richer metadata, source withdrawal, canonical identity, History samples
and permissions remain unchanged. A guest-agent state or cleared lock proves
neither filesystem thaw nor workload liveness.
The owning hook and adapter regressions cover REST/snapshot forwarding,
retained/unavailable/fresh transitions with unchanged numbers, explicit false,
measured zero, partial omissions and full/delta/fast recovery. The connected
`browser-tests/workload-read-evidence.cjs` checks actual production row, Overview
and History presentation in desktop Chromium and phone WebKit. Synthetic
source proof does not establish native backup safety or shipped availability.
### Backup server counts retain their inventory availability
The existing PBS server/datastore row takes backup-inventory read availability
@@ -22,6 +22,9 @@ const targets: Record<string, string> = {
malformed: '/docs/API#%invalid',
missing: '/docs/API#does-not-exist',
troubleshooting: '/docs/TROUBLESHOOTING#recovery-mode',
truenas: '/docs/TRUENAS',
installation: '/docs/INSTALL',
configuration: '/docs/CONFIGURATION#-api-tokens',
};
window.history.replaceState({}, '', targets[scenario] ?? targets.plain);
@@ -0,0 +1,220 @@
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/filesystem-usage-evidence';
const runtime = [
'frontend-modern/src/components/Workloads/diskListModel.ts',
'frontend-modern/src/components/Workloads/DiskList.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
];
const unknown = [
'missing-used',
'negative-used',
'NaN-used',
'infinite-used',
'missing-total',
'zero-total',
'negative-total',
'NaN-total',
'infinite-total',
'unknown-sentinel',
'NaN-usage',
'infinite-usage',
'overflow',
'retained-missing',
];
const digest = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
(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 hashes = Object.fromEntries(runtime.map((p) => [p, digest(path.join('/workspace', p))]));
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: 5302, strictPort: true },
});
const results = [];
const screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, height, dark] of [
['chromium-desktop', chromium, 1365, 900, false],
['chromium-phone', chromium, 390, 844, false],
['webkit-phone', webkit, 360, 844, true],
]) {
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 <= 768,
hasTouch: width <= 768,
locale: 'en-GB',
});
page.setDefaultTimeout(60000);
const pageErrors = [];
const apiRequests = [];
const offOrigin = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5302') {
offOrigin.push(url.origin);
return route.abort();
}
if (url.pathname.startsWith('/api/') || url.pathname === '/ws') {
apiRequests.push(url.pathname);
return route.abort();
}
return route.continue();
});
await page.goto('http://127.0.0.1:5302/browser-tests/filesystem-usage-evidence.html', {
waitUntil: 'domcontentloaded',
});
const select = page.getByLabel('Synthetic observation');
await select.waitFor({ state: 'visible' });
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
const list = page.getByRole('region', { name: 'Filesystem list' });
const overview = page.getByRole('region', { name: 'Guest Overview' });
// Overview owns a full-width guest drawer. A narrow fixture cap at an
// xl viewport would activate four columns inside an artificial 672px
// host and split the existing backup copy into single characters.
const backupBounds = await overview.getByText('No completed backup found').boundingBox();
assert(backupBounds && backupBounds.width >= 100 && backupBounds.height <= 40);
await page.evaluate(() => {
window.__listIdentity = document.querySelector('[aria-label="Filesystem list"]');
window.__overviewIdentity = document.querySelector(
'[data-testid="guest-technical-details"]',
);
});
const checks = [];
const selectState = async (state) => {
await select.selectOption(state);
assert.equal(
await page.evaluate(
() =>
window.__listIdentity === document.querySelector('[aria-label="Filesystem list"]') &&
window.__overviewIdentity ===
document.querySelector('[data-testid="guest-technical-details"]'),
),
true,
);
};
const capture = async (state) => {
const p = path.join(output, `${name}-${state}.png`);
await page.screenshot({ path: p, fullPage: true });
screenshots.push({ path: path.relative('/workspace', p), sha256: digest(p) });
};
for (const state of unknown) {
await selectState(state);
await overview.locator('[data-testid="guest-technical-details"]').waitFor();
for (const panel of [list, overview]) {
const text = await panel.innerText();
assert(text.includes('—'), `${state}: missing unavailable marker`);
assert(!/0%|NaN|Infinity/.test(text), `${state}: fabricated/invalid value: ${text}`);
assert(text.includes('/data') && text.includes('EXT4'));
assert.equal(await panel.getByRole('progressbar').count(), 0);
}
const listFill = list.locator('[style*="width:"]');
assert.equal(await listFill.evaluate((element) => element.style.width), '0%');
assert(!(await listFill.getAttribute('class')).includes('metric-normal'));
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
checks.push({ state, unknown: true, numericProgress: false, noOverflow: true });
if (['missing-used', 'infinite-total', 'retained-missing'].includes(state)) {
if (state === 'retained-missing') {
assert((await list.innerText()).includes('earlier guest request is still in progress'));
}
await capture(state);
}
}
for (const [state, percentage] of [
['zero', 0],
['live', 50],
['usage-omitted', 50],
['over-capacity', 125],
['retained-live', 50],
]) {
await selectState(state);
assert((await list.innerText()).includes(`${percentage}%`));
assert.equal(
await overview.getByRole('progressbar').getAttribute('aria-valuenow'),
String(Math.min(percentage, 100)),
);
checks.push({ state, percentage, measured: true });
if (state === 'zero') await capture(state);
}
// Real keyboard input on the fixture replaces the same observation;
// there is no new product control or collector action.
await selectState('missing-used');
await select.focus();
await page.keyboard.press('Home');
await page.keyboard.press('Enter');
assert.equal(await select.inputValue(), 'live');
assert.equal(await overview.getByRole('progressbar').getAttribute('aria-valuenow'), '50');
assert.equal(pageErrors.length, 0);
assert.equal(apiRequests.length, 0);
assert.equal(offOrigin.length, 0);
results.push({
name,
browserVersion,
viewport: { width, height },
dark,
checks,
pageErrors,
apiRequests,
offOrigin,
});
await browser.close();
browser = undefined;
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: 'passed',
verified_at: new Date().toISOString(),
playwrightVersion,
hashes,
results,
screenshots,
scope:
'Production DiskList and GuestDrawerOverview under synthetic same-filesystem observations. No native collection, installed/release acceptance or full source qualification.',
},
null,
2,
),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify({ error: error.message, results }, null, 2),
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
process.stderr.write(`${error.stack}\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" />
<title>Filesystem usage evidence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/filesystem-usage-evidence.tsx"></script>
</body>
</html>
@@ -0,0 +1,109 @@
// Production list and Overview, driven only by synthetic local observations.
// This fixture cannot collect guest data or establish native backup safety.
import { createMemo, createSignal, For } from 'solid-js';
import { render } from 'solid-js/web';
import { DiskList } from '../src/components/Workloads/DiskList';
import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
import type { Disk, VM } from '../src/types/api';
import '../src/index.css';
const capacity = 10 * 1024 ** 3;
const cases: Record<string, { values: Partial<Disk>; reason?: string }> = {
live: { values: {} },
'missing-used': { values: { used: undefined } },
'negative-used': { values: { used: -1 } },
'NaN-used': { values: { used: NaN } },
'infinite-used': { values: { used: Infinity } },
'missing-total': { values: { total: undefined } },
'zero-total': { values: { total: 0 } },
'negative-total': { values: { total: -1 } },
'NaN-total': { values: { total: NaN } },
'infinite-total': { values: { total: Infinity } },
'unknown-sentinel': { values: { usage: -1, used: 0 } },
'NaN-usage': { values: { usage: NaN } },
'infinite-usage': { values: { usage: Infinity } },
overflow: { values: { total: 1, used: Number.MAX_VALUE } },
zero: { values: { used: 0, usage: undefined } },
'usage-omitted': { values: { usage: undefined } },
'over-capacity': { values: { used: capacity * 1.25, usage: 0 } },
'retained-live': { values: {}, reason: 'prev-vm-locked' },
'retained-missing': { values: { used: undefined }, reason: 'prev-agent-busy' },
};
function Fixture() {
const [selected, setSelected] = createSignal('live');
const observation = createMemo(() => ({
mountpoint: '/data',
type: 'ext4',
total: capacity,
used: capacity / 2,
usage: 50,
...cases[selected()].values,
}));
const guest = createMemo<VM>(() => ({
id: 'fixture:pve-a:101',
vmid: 101,
name: 'filesystem-evidence',
node: 'pve-a',
instance: 'fixture',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
disk: observation(),
disks: [observation()],
diskStatusReason: cases[selected()].reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: '',
lastSeen: '2026-10-04T04:00:00Z',
}));
return (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Filesystem usage evidence</h1>
<label for="fixture-observation" class="flex flex-wrap items-center gap-2 text-sm">
Synthetic observation
<select
id="fixture-observation"
class="rounded-sm border border-border bg-surface p-2"
value={selected()}
onChange={(event) => setSelected(event.currentTarget.value)}
>
<For each={Object.keys(cases)}>{(name) => <option value={name}>{name}</option>}</For>
</select>
</label>
<section aria-label="Filesystem list" class="max-w-xl">
<DiskList disks={[observation()]} diskStatusReason={cases[selected()].reason} />
</section>
<section aria-label="Guest Overview">
<GuestDrawerOverview
guest={guest()}
guestOsSummary=""
agentHeading="Guest agent"
agentLabel=""
agentTitle=""
hasAgentInfo={false}
hasFilesystemDetails={true}
hasNetworkInterfaces={false}
hasOsInfo={false}
hasWorkloadActionAgent={false}
showInGuestAgentInstallCue={false}
ipAddresses={[]}
networkInterfaces={[]}
normalizedTags={[]}
backupPresentation={null}
workloadActionAgentTitle=""
/>
</section>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,340 @@
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/guest-history-provenance';
const origin = 'http://127.0.0.1:5316';
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
];
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const settle = (page) =>
page.evaluate(async () => {
await document.fonts.ready;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
(async () => {
fs.mkdirSync(output, { recursive: true });
const result = {
result: 'incomplete',
playwright: require('playwright/package.json').version,
runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])),
cases: [],
screenshots: [],
limits:
'Production full GuestDrawer/History, API client and CSS with synthetic local responses. Not native QGA/thaw, provider collection, physical-phone, installed recovery or release acceptance.',
};
assert.equal(
result.playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
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: output + '/cache',
server: { host: '127.0.0.1', port: 5316, strictPort: true },
});
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop', chromium, 1365, false],
['chromium-phone', chromium, 390, false],
['webkit-phone', webkit, 360, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 768 ? 844 : 900 },
isMobile: width < 768,
hasTouch: width < 768,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
let mode = 'empty';
const record = {
name,
width,
dark,
browser: browser.version(),
errors: [],
requests: [],
states: [],
checks: [],
};
result.cases.push(record);
page.on('pageerror', (error) => record.errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin) {
record.requests.push({ origin: url.origin, blocked: true });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
record.requests.push({ path: url.pathname, method: route.request().method(), mode });
if (url.pathname === '/api/metrics-store/history') {
if (mode === '403' || mode === '503')
return route.fulfill({
status: Number(mode),
json: { error: 'fixture-private-error-body' },
});
const start = Date.UTC(2026, 9, 4, 2),
end = start + 60 * 60_000;
const point = (timestamp, value) => ({ timestamp, value, min: value, max: value });
const metrics =
mode === 'stored'
? {
cpu: [point(start, 10), point(end, 15)],
disk: [point(start, 20), point(end, 30)],
}
: mode === 'cpu-only'
? { cpu: [point(start, 10), point(end, 15)] }
: {};
return route.fulfill({
json: {
resourceType: url.searchParams.get('resourceType'),
resourceId: url.searchParams.get('resourceId'),
range: url.searchParams.get('range'),
start,
end,
metrics,
source: 'store',
},
});
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.addInitScript(
(isDark) =>
document.addEventListener('DOMContentLoaded', () => {
if (isDark) document.documentElement.classList.add('dark');
}),
dark,
);
await page.goto(origin + '/browser-tests/guest-history-provenance.html');
await page.waitForFunction(() => window.__guestHistoryProvenance, undefined, {
timeout: 60000,
});
const activate = (locator) => (width < 768 ? locator.tap() : locator.click());
await activate(page.getByRole('tab', { name: 'History', exact: true }));
const chart = page.locator('[data-history-group="utilization"]');
const notice = chart.locator('[data-history-deferred="disk"]');
const waitRead = async () => {
await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]'));
await settle(page);
};
const observe = async (next) => {
await page.evaluate((value) => window.__guestHistoryProvenance.observe(value), next);
await waitRead();
};
const screenshot = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({
path: output + '/' + file,
fullPage: true,
animations: 'disabled',
});
result.screenshots.push({ file, sha256: hash(output + '/' + file), state, name });
};
const checkGeometry = async () => {
const geometry = await notice.evaluate((el) => {
const rect = el.getBoundingClientRect(),
group = el.closest('section').getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(el);
return {
left: rect.left,
right: rect.right,
bottom: rect.bottom,
groupRight: group.right,
textRight: Math.max(...Array.from(range.getClientRects(), (r) => r.right)),
plotTop: el.nextElementSibling.getBoundingClientRect().top,
overflow: document.documentElement.scrollWidth > innerWidth,
};
});
assert.equal(geometry.overflow, false);
assert.ok(
geometry.left >= 0 &&
geometry.right <= width &&
geometry.textRight <= geometry.groupRight + 1,
);
assert.ok(geometry.bottom <= geometry.plotTop + 1);
return geometry;
};
await waitRead();
const reasons = await page.evaluate(() => window.__guestHistoryProvenance.deferrals);
for (const [reason, message] of reasons) {
for (const retained of [true, false]) {
await observe({
reason: (retained ? 'prev-' : '') + reason,
usage: 50,
lock: reason === 'vm-locked' ? 'backup' : '',
});
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
assert.equal(
await chart.locator('[data-history-last-known="disk"]').count(),
retained ? 1 : 0,
);
assert.equal((await notice.innerText()).includes(message), true);
if (retained) {
const legend = chart.locator('[data-history-last-known="disk"]');
assert.match(await legend.innerText(), /50\.0%[\s\S]*last known/);
assert.equal(
await legend.evaluate((el) =>
document
.getElementById(el.getAttribute('aria-describedby'))
.textContent.includes('Using last known'),
),
true,
);
}
assert.equal(await chart.locator('path').count(), 0);
assert.equal(await chart.locator('[data-history-observation]').count(), 0);
assert.equal(await chart.locator('[data-history-current="cpu"]').count(), 1);
record.states.push({ reason, retained, geometry: await checkGeometry() });
}
}
await observe({ reason: 'prev-vm-locked', usage: 0 });
assert.match(
await chart.locator('[data-history-last-known="disk"]').innerText(),
/0\.0%[\s\S]*last known/,
);
record.checks.push('Retained zero is not lost or plotted.');
await observe({ reason: 'prev-vm-locked', usage: 50, lock: 'backup' });
await screenshot('last-known');
await observe({ reason: 'prev-agent-busy', lock: '' });
assert.match(await notice.innerText(), /earlier guest request is still in progress/);
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
record.checks.push('Lock clearance alone does not restore current evidence.');
await observe({ reason: 'vm-locked', usage: -1 });
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
await screenshot('unavailable');
await observe({ reason: 'prev-agent-timeout', usage: 50 });
mode = 'stored';
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
await page.waitForFunction(
() => document.querySelectorAll('[data-history-group="utilization"] path').length === 2,
);
await settle(page);
const paths = await chart
.locator('path')
.evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d')));
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
assert.match(
await notice.innerText(),
/Completion is uncertain\. Do not restart the guest agent during a backup/,
);
const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
if (width < 768) await slider.tap({ position: { x: 3, y: 20 } });
else {
await slider.focus();
await slider.press('Home');
}
await settle(page);
assert.match(await slider.getAttribute('aria-valuetext'), /Disk 20\.0%/);
assert.equal(await chart.locator('[data-history-current]').count(), 0);
assert.deepEqual(
await chart.locator('path').evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d'))),
paths,
);
await screenshot('stored-inspection');
await slider.blur();
record.checks.push(
'Keyboard/touch inspection uses actual stored 20% at its own date, not retained 50%; paths remain unchanged.',
);
mode = 'cpu-only';
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
await page.waitForFunction(
() => document.querySelectorAll('[data-history-group="utilization"] path').length === 1,
);
await settle(page);
await slider.focus();
await slider.press('Home');
assert.match(await slider.getAttribute('aria-valuetext'), /Disk no observation/);
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
await slider.blur();
assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1);
record.checks.push('A dated CPU observation cannot borrow the retained filesystem value.');
mode = '503';
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
await page.getByRole('button', { name: 'Retry history', exact: true }).waitFor();
assert.equal(await chart.locator('path').count(), 1);
assert.equal(await notice.count(), 1);
assert.equal(
(await page.locator('main').innerText()).includes('fixture-private-error-body'),
false,
);
mode = '403';
await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
await page.waitForFunction(() => !document.querySelector('[data-history-group]'));
assert.equal(await notice.count(), 0);
assert.equal(await page.locator('[data-history-last-known]').count(), 0);
record.checks.push(
'Transient failure keeps prior stored data with warning; final access denial withdraws charts, fallback and notices.',
);
mode = 'empty';
await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
await notice.waitFor();
await waitRead();
await observe({ reason: '', usage: 75, lock: '' });
assert.equal(await notice.count(), 0);
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
assert.match(
await chart.locator('[data-history-current="disk"]').innerText(),
/75\.0%[\s\S]*current/,
);
assert.equal(await chart.locator('path').count(), 0);
await screenshot('fresh-resumption');
record.checks.push(
'Successful read and fresh same-VM data restore current 75%, clear provenance, and create no history points.',
);
await observe({ reason: 'prev-vm-locked', kind: 'lxc', usage: 50 });
assert.equal(await notice.count(), 0);
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1);
assert.equal(
record.requests.every((request) => request.method === 'GET' && !request.blocked),
true,
);
assert.deepEqual(record.errors, []);
await page.close();
await browser.close();
browser = null;
}
result.result = 'passed';
} catch (error) {
result.error = { name: error.name, message: error.message };
throw error;
} finally {
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
process.stderr.write(error.stack + '\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>Guest History filesystem provenance verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-history-provenance.tsx"></script>
</body>
</html>
@@ -0,0 +1,85 @@
// Full production drawer/CSS and client; observations and API responses are
// synthetic. No backup, guest-agent or native recovery operation is performed.
import { createMemo, createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { WorkloadGuest } from '../src/types/workloads';
import { guestDiskDeferrals } from '../src/components/Workloads/__fixtures__/guestDiskDeferrals';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
function Fixture() {
const [observation, setObservation] = createSignal({
reason: 'prev-vm-locked',
usage: 50,
lock: 'backup',
kind: 'qemu',
});
const guest = createMemo<WorkloadGuest>(() => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type: observation().kind,
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: {
total: 10 * 1024 ** 3,
used: (observation().usage / 100) * 10 * 1024 ** 3,
usage: observation().usage,
},
diskStatusReason: observation().reason,
lock: observation().lock,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
backupInProgress: Boolean(observation().lock),
tags: [],
lastSeen: '2026-10-04T03:00:00Z',
}));
(window as unknown as Record<string, unknown>).__guestHistoryProvenance = {
observe: (next: Partial<ReturnType<typeof observation>>) =>
setObservation((prev) => ({ ...prev, ...next })),
deferrals: guestDiskDeferrals,
};
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest History filesystem provenance</h1>
<p class="text-xs text-muted">
Synthetic observations. No backup or guest-agent command is sent.
</p>
<section class="mx-auto max-w-5xl" aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => {}} />
</section>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,125 @@
// Render the shipped help and follow its recovery/agent links. Never execute
// any removal command: this verifies guidance, not native uninstall behaviour.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'server-removal-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-server-removal-help'),
server: { host: '127.0.0.1', port: 5252, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html?scenario=installation',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
const uninstall = page.getByRole('heading', { level: 2, name: '🗑️ Uninstall', exact: true });
await uninstall.waitFor();
if (engine === 'webkit') {
await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
}
const section = await uninstall.evaluate((heading) => {
const nodes = [];
for (let node = heading.nextElementSibling; node; node = node.nextElementSibling)
nodes.push(node);
return {
text: nodes.map((node) => node.innerText).join(' ').replace(/\s+/g, ' '),
commands: nodes.filter((node) => node.tagName === 'PRE').flatMap((node) =>
node.innerText.replace(/\\\n\s*/g, ' ').trim().split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())),
commandBoxes: nodes.filter((node) => node.tagName === 'PRE').map((node) => ({
clientWidth: node.clientWidth, scrollWidth: node.scrollWidth,
})),
};
});
for (const text of ['stops monitoring and alert delivery', 'Keep persistent data by default',
'every effective data path', 'A configuration export alone is not a full backup',
'container\'s writable layer or temporary storage', '--rm can also delete anonymous volumes',
'reattach the same data mount', 'prefixes volume names', 'without a keep policy',
'reclaim policy can delete its backing data', 'emptyDir storage, lost when the pod is removed',
'inside the Pulse container', 'disable Pulse, not fully uninstall it',
'without a keep-data prompt', 'Removing the server does not remove agents'])
assert.ok(section.text.includes(text), text);
assert.deepEqual(section.commands, [
'docker stop pulse', 'docker rm pulse', 'docker compose stop pulse', 'docker compose rm pulse',
'kubectl scale deployment pulse --namespace pulse --replicas=0',
'sudo systemctl disable --now pulse-update.timer', 'sudo systemctl disable --now pulse.service',
]);
assert.ok(section.commandBoxes.every((box) => box.scrollWidth <= box.clientWidth + 1),
'removal commands must fit without phone clipping');
for (const [name, heading] of [
['docker', uninstall],
['kubernetes', page.getByRole('heading', { level: 3,
name: 'Kubernetes: check claim ownership before uninstalling', exact: true })],
['systemd', page.getByRole('heading', { level: 3,
name: 'Systemd / Proxmox LXC: disable without erasing data', exact: true })],
]) {
await heading.evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) });
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
const backup = page.getByRole('link', { name: 'full-state backup', exact: true });
assert.equal(await backup.getAttribute('href'), '/docs/MIGRATION#full-state-recovery');
await backup.focus();
await page.keyboard.press('Enter');
await page.waitForURL('**/docs/MIGRATION#full-state-recovery');
await page.getByRole('heading', { level: 3, name: 'Full-state recovery', exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.activeElement?.id), 'full-state-recovery');
await page.goBack();
await uninstall.waitFor();
const agent = page.getByRole('link', { name: 'uninstall procedure', exact: true });
assert.equal(await agent.getAttribute('href'), '/docs/UNIFIED_AGENT#uninstall');
await agent.focus();
await page.keyboard.press('Enter');
await page.waitForURL('**/docs/UNIFIED_AGENT#uninstall');
await page.getByRole('heading', { level: 2, name: 'Uninstall', exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.activeElement?.id), 'uninstall');
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width,
persistentDataWarning: true, commandsFit: true, noDocumentOverflow: true,
helmRetentionWarning: true, installerErasureWarning: true,
keyboardFullStateRecovery: true, keyboardAgentUninstall: true, errors });
await browser.close();
browser = undefined;
}
const files = ['public/docs/INSTALL.md', 'browser-tests/server-removal-help.cjs',
'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts',
'src/pages/Docs.tsx'].map((file) => ({ file,
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped server-removal help rendering and keyboard links; no removal/native commands executed',
dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version,
files, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,104 @@
// Exercise the real Docs renderer/router/styles and shipped installation guide.
// This is help verification, not an installed service migration or upgrade.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'systemd-install-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-systemd-install-help'),
server: { host: '127.0.0.1', port: 5251, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5251/browser-tests/docs-fragment-navigation.html?scenario=installation',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
const summary = page.locator('summary').filter({
hasText: 'Manual or custom systemd services (advanced)',
});
await summary.waitFor();
if (engine === 'webkit') {
await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
}
const details = summary.locator('..');
assert.equal(await details.getAttribute('open'), null);
await summary.focus();
await page.keyboard.press('Enter');
assert.notEqual(await details.getAttribute('open'), null);
const content = await details.innerText();
for (const text of ['use the signed installer above', 'User=pulse', 'Group=pulse',
'NoNewPrivileges=true', 'ProtectSystem=strict', 'LoadState=loaded',
'not that a root service is running', 'an empty User=',
'the installer preserves existing service units during updates',
'Do not overwrite a working unit, change only its service user',
'preserve the data directory and its encryption key',
'separate host agent has different privilege requirements'])
assert.ok(content.includes(text), text);
const command = await details.locator('pre').innerText();
assert.ok(command.startsWith('systemctl show pulse.service'));
assert.ok(command.includes('--property=LoadState'));
assert.ok(Math.max(...command.split('\n').map((line) => line.length)) < 34);
assert.ok(!/--property=(Environment|ExecStart)|sudo|tee|install -m/.test(command));
assert.ok(!await page.locator('article').innerText().then((text) =>
text.includes('sudo tee /etc/systemd/system/pulse.service')));
await summary.evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-account.png`) });
await details.locator('pre').evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-checks.png`) });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
const recovery = details.getByRole('link', { name: 'the recovery guide', exact: true });
assert.equal(await recovery.getAttribute('href'), '/docs/RECOVERY');
await recovery.focus();
await page.keyboard.press('Enter');
await page.waitForURL('**/docs/RECOVERY');
await page.getByRole('heading', { level: 1 }).filter({ hasText: 'Recovery' }).waitFor();
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width,
keyboardDisclosure: true, nonRootServer: true, agentBoundary: true,
readOnlyCredentialSafeCommand: true, loadedUnitRequired: true, phoneCommandFits: true,
preservationWarning: true,
noDocumentOverflow: true, keyboardRecoveryLink: true, errors });
await browser.close();
browser = undefined;
}
const files = ['public/docs/INSTALL.md', 'browser-tests/systemd-install-help.cjs',
'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts',
'src/pages/Docs.tsx'].map((file) => ({ file,
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'existing shipped installation-help rendering and navigation, not installed/systemd acceptance',
dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version,
files, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,119 @@
// Inspect the existing shipped help using the production Docs renderer.
// This creates no token, authenticates no client and performs no rotation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'token-guidance-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-token-guidance'),
server: { host: '127.0.0.1', port: 5253, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html?scenario=configuration',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
const tokens = page.getByRole('heading', { name: '🔑 API Tokens', exact: true });
await tokens.waitFor();
await page.waitForFunction(() => document.activeElement?.id === '-api-tokens');
if (engine === 'webkit') {
await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
}
const presetTable = page.getByRole('table').filter({ has: page.getByRole('columnheader',
{ name: 'Preset', exact: true }) });
const agent = presetTable.getByRole('row').filter({ has: page.getByRole('cell',
{ name: 'Agent', exact: true }) });
assert.equal(await agent.getByRole('cell').nth(1).innerText(), 'agent:report, agent:config:read');
assert.equal(await presetTable.getByRole('row').count(), 8);
const scopesTable = page.getByRole('table').filter({ has: page.getByRole('columnheader',
{ name: 'Scope', exact: true }) });
assert.equal(await scopesTable.getByRole('row').count(), 17);
for (const table of [presetTable, scopesTable]) {
assert.ok(await table.evaluate((element) => {
const parent = element.parentElement;
return parent.dataset.docTableScroll !== undefined &&
getComputedStyle(parent).overflowX === 'auto' &&
[...element.querySelectorAll('thead th')].every((header) => header.scope === 'col');
}));
}
const body = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
for (const text of ['Settings → API Access', 'Reporting is not remote execution',
'not to fix a missing reading', 'revoke it promptly', 'even if that interrupts monitoring',
'Magic Kiosk Link', 'Copy Link', 'no administrator session', 'bearer credential',
'initial request already carried the token', 'proxy/server logs',
'Do not rely on this session surviving']) assert.ok(body.includes(text), text);
assert.ok(!body.includes('?token=YOUR_TOKEN_HERE'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
for (const [name, heading] of [
['rotation', tokens],
['presets', page.getByRole('heading', { name: 'Presets', exact: true })],
['kiosk', page.getByRole('heading', { name: 'Kiosk Mode', exact: true })],
]) {
await heading.evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) });
}
for (const [name, href, heading, id] of [
['private header-file procedure', '/docs/API#api-token-recommended',
'API Token (Recommended)', 'api-token-recommended'],
['agent retargeting', '/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address',
'Moving Pulse to a new address', 'moving-pulse-to-a-new-address'],
]) {
const link = page.getByRole('link', { name, exact: true });
assert.equal(await link.getAttribute('href'), href);
await link.focus();
await page.keyboard.press('Enter');
await page.waitForURL(`**${href}`);
await page.getByRole('heading', { name: heading, exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.activeElement?.id), id);
await page.goBack();
await tokens.waitFor();
}
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width,
tokenAnchorFocus: true, leastPrivilegeAgentRow: true, actualPresetCount: 7,
scopeCount: 16, semanticContainedTables: true, noDocumentOverflow: true,
kioskCredentialWarning: true, keyboardApiHelp: true, keyboardAgentRetargeting: true, errors });
await browser.close();
browser = undefined;
}
const files = ['public/docs/CONFIGURATION.md', 'browser-tests/token-guidance.cjs',
'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts',
'src/pages/Docs.tsx'].map((file) => ({ file,
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped token guidance rendering and keyboard help links; no token/native operation performed',
dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version,
files, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,81 @@
// The real Docs page/router/styles and shipped TrueNAS help, without a backend.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'truenas-connection-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-truenas-connection-help'),
server: { host: '127.0.0.1', port: 5250, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html?scenario=truenas',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
const quickStart = page.getByRole('heading', { name: 'Quick Start', exact: true });
await quickStart.waitFor();
if (engine === 'webkit') {
await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
}
const article = await page.locator('article').innerText();
for (const text of ['it does not validate inventory or metric collection',
'an elapsed interval is not proof that a poll completed',
'Some older builds, including Pulse 6.4.1 and 6.4.5',
'use the inventory observation time rather than the test time',
'do not copy a token or cookie', 'Do not post the full connection response'])
assert.ok(article.includes(text), text);
assert.ok(!article.includes('Data appears within one configured polling cycle'));
await quickStart.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: path.join(artifacts, `${engine}-setup.png`) });
const checks = page.getByRole('link', { name: 'polling checks', exact: true }).first();
await checks.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const heading = document.getElementById('stale-truenas-data');
return heading && heading.getBoundingClientRect().top >= 0
&& heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/TRUENAS#stale-truenas-data'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-polling.png`) });
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width,
probeScope: true, noGuaranteedPoll: true, versionQualifiedWarning: true,
keyboardPollingLink: true, noDocumentOverflow: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped help rendering/navigation, not native TrueNAS polling', results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,354 @@
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/workload-read-evidence';
const origin = 'http://127.0.0.1:5318';
const runtime = [
'frontend-modern/src/hooks/useWorkloads.ts',
'frontend-modern/src/components/Workloads/useGuestRowState.ts',
'frontend-modern/src/utils/resourceStateAdapters.ts',
];
const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const initial = () => ({
reason: 'prev-vm-locked',
usage: 50,
lock: 'backup',
status: 'deferred',
expected: true,
});
const resource = (read) => {
const capacity = 10 * 1024 ** 3;
return {
id: 'fixture-a-pve1-101',
type: 'vm',
name: 'backup-guest',
status: 'online',
sources: ['proxmox'],
lastSeen: '2026-10-04T05:00:00Z',
metrics: {
cpu: { percent: 10 },
memory: { percent: 25, used: capacity / 4, total: capacity },
...(read.usage === undefined
? {}
: { disk: { percent: read.usage, used: (capacity * read.usage) / 100, total: capacity } }),
},
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: 'fixture-a',
runtimeStatus: 'running',
guestAgentStatus: read.status,
disks:
read.usage === undefined
? []
: [
{
mountpoint: '/data',
filesystem: 'ext4',
total: capacity,
used: (capacity * read.usage) / 100,
},
],
...(read.reason === undefined ? {} : { diskStatusReason: read.reason }),
...(read.lock === undefined ? {} : { lock: read.lock }),
...(read.expected === undefined ? {} : { guestAgentExpected: read.expected }),
},
};
};
(async () => {
fs.mkdirSync(output, { recursive: true });
const result = {
result: 'incomplete',
playwright: require('playwright/package.json').version,
runtime_hashes: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])),
cases: [],
screenshots: [],
limits:
'Real useWorkloads API client and owned snapshot, full/delta/fast canonical merges, production GuestRow and GuestDrawer/CSS. Synthetic read state and local API responses; no native QGA/thaw, workload liveness, installed or physical-phone acceptance, publication or release proof.',
};
assert.equal(
result.playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
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: output + '/cache',
server: { host: '127.0.0.1', port: 5318, strictPort: true },
});
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop', chromium, 1365, false],
['webkit-phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const transport of ['api', 'canonical-full', 'canonical-delta', 'canonical-fast']) {
const page = await browser.newPage({
viewport: { width, height: width < 768 ? 844 : 900 },
isMobile: width < 768,
hasTouch: width < 768,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
let read = initial();
const record = {
name,
transport,
width,
dark,
browser: browser.version(),
errors: [],
requests: [],
states: [],
};
result.cases.push(record);
page.on('pageerror', (e) => record.errors.push(e.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin) {
record.requests.push({ origin: url.origin, blocked: true });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
record.requests.push({ path: url.pathname, method: route.request().method() });
assert.equal(route.request().method(), 'GET');
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: [resource(read)], meta: { totalPages: 1 } } });
if (url.pathname === '/api/metrics-store/history')
return route.fulfill({
json: {
resourceType: url.searchParams.get('resourceType'),
resourceId: url.searchParams.get('resourceId'),
range: url.searchParams.get('range'),
start: Date.UTC(2026, 9, 4, 2),
end: Date.UTC(2026, 9, 4, 3),
metrics: {},
source: 'store',
},
});
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.addInitScript(
(isDark) =>
document.addEventListener('DOMContentLoaded', () => {
if (isDark) document.documentElement.classList.add('dark');
}),
dark,
);
await page.goto(
origin + '/browser-tests/workload-read-evidence.html?transport=' + transport,
);
await page.getByRole('region', { name: 'Workload row' }).waitFor();
const row = page.getByRole('region', { name: 'Workload row' });
const cell = row.locator('[data-workload-col="disk"]');
const badge = row.locator('[data-workload-disk-read-status]');
const drawer = page.getByRole('region', { name: 'Guest details' });
const activate = (locator) => (width < 768 ? locator.tap() : locator.click());
const history = drawer.getByRole('tab', { name: 'History', exact: true });
await activate(history);
const chart = drawer.locator('[data-history-group="utilization"]');
await chart.waitFor();
await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]'));
await page.evaluate(() => {
window.__rowIdentity = document.querySelector('[data-workload-col="disk"]');
window.__chartIdentity = document.querySelector('[data-history-group="utilization"]');
});
const evidence = () => page.evaluate(() => window.__workloadReadEvidence.evidence());
const id = (await evidence()).id;
const observe = async (value) => {
read = value;
await page.evaluate(async (next) => window.__workloadReadEvidence.update(next), value);
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
assert.equal((await evidence()).id, id);
assert.equal(
await page.evaluate(
() => window.__rowIdentity === document.querySelector('[data-workload-col="disk"]'),
),
true,
);
assert.equal(
await page.evaluate(
() =>
window.__chartIdentity ===
document.querySelector('[data-history-group="utilization"]'),
),
true,
);
};
const check = async (state, reason, retained, usage, lock, agent, expected) => {
const current = await evidence();
assert.equal(current.reason, reason);
assert.equal(current.lock, lock || '');
assert.equal(current.agent, agent);
assert.equal(current.expected, expected);
const trigger = cell.locator('[data-stacked-disk-trigger]');
assert.equal(await trigger.count(), reason && !retained ? 0 : 1);
if (!reason || retained) {
await page.waitForFunction((text) => {
const value = document.querySelector(
'[data-workload-col="disk"] [data-animated-number]',
);
return (
value && value.textContent === text && value.getAttribute('aria-label') === text
);
}, usage + '%');
} else {
assert.equal(await cell.locator('[data-animated-number]').count(), 0);
assert.equal((await cell.innerText()).includes('50%'), false);
}
assert.equal(await badge.count(), reason ? 1 : 0);
if (reason) {
assert.match(
await badge.innerText(),
retained ? /Last known|Prior/ : /Unavailable|N\/A/,
);
assert.match(
await cell.getAttribute('title'),
retained ? /Using last known/ : /Guest reads|Guest request/,
);
} else assert.equal(await cell.getAttribute('title'), null);
assert.equal(/lock:/i.test(await row.innerText()), Boolean(lock));
assert.equal(
await chart.locator('[data-history-current="disk"]').count(),
reason ? 0 : 1,
);
assert.equal(
await chart.locator('[data-history-last-known="disk"]').count(),
retained ? 1 : 0,
);
assert.equal(
await chart.locator('[data-history-deferred="disk"]').count(),
reason ? 1 : 0,
);
if (retained)
assert.match(
await chart.locator('[data-history-last-known="disk"]').innerText(),
new RegExp(usage.toFixed(1) + '%[\\s\\S]*last known'),
);
if (!reason)
assert.match(
await chart.locator('[data-history-current="disk"]').innerText(),
new RegExp(usage.toFixed(1) + '%'),
);
assert.equal(await chart.locator('path').count(), 0); // no invented trend from the snapshot
assert.equal(await chart.locator('[data-history-observation]').count(), 0);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
false,
);
record.states.push({ state, evidence: current });
};
const screenshot = async (state) => {
if (!(
(transport === 'api' && name === 'chromium-desktop') ||
(transport === 'canonical-fast' && name === 'webkit-phone')
))
return;
const file = `${name}-${transport}-${state}.png`;
await page.screenshot({
path: output + '/' + file,
fullPage: true,
animations: 'disabled',
});
result.screenshots.push({
file,
sha256: hash(output + '/' + file),
state,
name,
transport,
});
};
await check('retained-backup', 'prev-vm-locked', true, 50, 'backup', 'deferred', true);
await screenshot('retained');
// No lock on wire does not by itself restore current disk data.
await observe({ reason: 'prev-agent-busy', usage: 50, status: 'deferred', expected: true });
await check(
'lock-cleared-still-deferred',
'prev-agent-busy',
true,
50,
'',
'deferred',
true,
);
// Omitted reason/lock with a fresh explicit native outcome supersedes the old fields.
await observe({ usage: 50, status: 'available' });
await check('fresh-same-number', undefined, false, 50, '', 'available', undefined);
await observe({ reason: 'agent-timeout', status: 'deferred', expected: false });
await check('unavailable', 'agent-timeout', false, 0, '', 'deferred', false);
assert.match(
await chart.locator('[data-history-deferred="disk"]').innerText(),
/Completion is uncertain.*Do not restart the guest agent during a backup/,
);
await screenshot('unavailable');
await observe({ usage: 0, status: 'available' });
await check('fresh-measured-zero', undefined, false, 0, '', 'available', undefined);
await screenshot('zero');
// Existing Overview updates as well; real History tab can be revisited with keyboard/touch.
await activate(drawer.getByRole('tab', { name: 'Overview', exact: true }));
assert.equal((await drawer.innerText()).includes('Using last known'), false);
assert.match(await drawer.innerText(), /0%/);
await activate(drawer.getByRole('tab', { name: 'History', exact: true }));
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1);
assert.equal(await chart.locator('[data-history-deferred="disk"]').count(), 0);
const resourceRequests = record.requests.filter((r) => r.path === '/api/resources');
if (transport !== 'api') assert.equal(resourceRequests.length, 0);
assert.deepEqual(record.errors, []);
assert.equal(
record.requests.some((r) => r.blocked),
false,
);
record.checks = [
'Native read-state forwarded rather than discarded.',
'Recovery clears obsolete optional state without remounting row/History.',
'Lock clearance alone preserves explicit deferral.',
'Measured zero remains current; snapshot values never become stored points.',
'Real tab activation, readable narrow layout, no page errors, mutations or external requests.',
'Owned snapshot never fetches a second workload inventory.',
];
await page.close();
}
await browser.close();
browser = undefined;
}
result.result = 'passed';
result.verified_at = new Date().toISOString();
} finally {
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
process.stderr.write(error.stack + '\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>Workload read evidence fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/workload-read-evidence.tsx"></script>
</body>
</html>
@@ -0,0 +1,164 @@
// Real workload adapter/merges and production panels, synthetic read snapshots.
// No backup, guest-agent, freeze/thaw or native recovery operation is performed.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { useWorkloads } from '../src/hooks/useWorkloads';
import {
mergeCanonicalResourceSnapshot,
mergeCanonicalResourceDeltaSnapshot,
} from '../src/utils/resourceStateAdapters';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { Resource } from '../src/types/resource';
import type { VM } from '../src/types/api';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
const capacity = 10 * 1024 ** 3;
type Read = {
reason?: string;
usage?: number;
lock?: string;
status: string;
expected?: boolean;
};
const initial: Read = {
reason: 'prev-vm-locked',
usage: 50,
lock: 'backup',
status: 'deferred',
expected: true,
};
const nativeResource = (read: Read): Resource => ({
id: 'fixture-a-pve1-101',
type: 'vm',
name: 'backup-guest',
displayName: 'backup-guest',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'online',
sources: ['proxmox'],
lastSeen: Date.UTC(2026, 9, 4, 5),
cpu: { current: 10 },
memory: { current: 25, used: capacity / 4, total: capacity },
...(read.usage === undefined
? {}
: {
disk: { current: read.usage, used: (capacity * read.usage) / 100, total: capacity },
}),
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: 'fixture-a',
runtimeStatus: 'running',
guestAgentStatus: read.status,
disks:
read.usage === undefined
? []
: [
{
mountpoint: '/data',
type: 'ext4',
total: capacity,
used: (capacity * read.usage) / 100,
usage: read.usage,
},
],
...(read.reason === undefined ? {} : { diskStatusReason: read.reason }),
...(read.lock === undefined ? {} : { lock: read.lock }),
...(read.expected === undefined ? {} : { guestAgentExpected: read.expected }),
},
});
function Fixture() {
const transport = new URLSearchParams(window.location.search).get('transport') || 'api';
const [snapshot, setSnapshot] = createSignal<Resource[]>(
mergeCanonicalResourceSnapshot([nativeResource(initial)], []),
);
const state = useWorkloads(
() => true,
transport === 'api'
? {}
: {
resourceSnapshot: snapshot,
},
);
const guest = () => state.workloads()[0]!;
(window as any).__workloadReadEvidence = {
update: async (read: Read) => {
if (transport === 'api') {
await state.refetch();
return;
}
const incoming = nativeResource(read);
setSnapshot((previous) =>
transport === 'canonical-full'
? mergeCanonicalResourceSnapshot([incoming], previous)
: mergeCanonicalResourceDeltaSnapshot(
[incoming],
previous,
new Set([incoming.id]),
transport === 'canonical-fast'
? new Map([[incoming.id, ['proxmox', 'disk']]])
: undefined,
),
);
},
evidence: () => {
const vm = guest() as VM;
return {
id: vm.id,
reason: vm.diskStatusReason,
lock: vm.lock,
agent: vm.guestAgentStatus,
expected: vm.guestAgentExpected,
usage: vm.disk.usage,
available: guest().telemetryAvailability?.disk,
};
},
};
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Workload read evidence</h1>
<p class="text-xs text-muted">
Synthetic {transport} observations. No guest commands are sent.
</p>
<Show when={Boolean(guest())}>
<section aria-label="Workload row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
</section>
<section aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => {}} />
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "d77f56bee4e2e79c9c41f1244f9a1b3e65b60dbb",
"verified_at": "2026-10-04T03:28:54Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "8713b06fd7a8ec67d111b1b97ff84c9421e9bd738b769b28c09d5217dd41f50a",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "7422c574ca1dc1ce8ffc9d28b176656a9f9be951f319f1fa8ef38e8a9b4aec8c",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "eb057472aa06df1aa2f687a6d99f6409ecf849817fc70fad1e4c23e623bb1d9f"
},
"routes": [
"/browser-tests/guest-history-provenance.html"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 360,
"height": 844
}
],
"states": [
"Production full GuestDrawer, History renderer, model, API client and CSS with synthetic transport/observations; desktop/phone Chromium and dark phone WebKit. Playwright 1.56.1 matches integration lock. All twelve final screenshots opened.",
"48 fixed-deferral states: eight reasons x retained/unavailable x three contexts, visible safe reason with fitting geometry, independently current CPU/memory and never a current disk during deferral. Retained zero and pre-sample unavailable controls pass.",
"Stored disk 20/30% remains dated chart evidence, independent from retained live 50%; dated CPU-only inspection reports no disk observation. Fallbacks never become plotted points.",
"No physical phone, native QGA/backup/thaw/collection, installed recovery, release publication or customer acceptance established. Earlier failed fixture assertions/startup/tab attempts retained separately; only corrected fixture is accepted."
],
"interactions": [
"Real mouse/phone touch opens the existing full drawer History tab and native refresh; desktop keyboard Home and phone touch range inspection use actual stored 20% at its own date without altering paths.",
"Lock clearance with an in-flight read reason keeps the live note and last-known context. Successful fresh same-VM 75% withdraws provenance, updates current fallback and creates no history. Non-VM control is unaffected.",
"Real same-origin synthetic 503 retains stored observations with warning; final 403 withdraws chart, fallback and deferral note until successful recovery. Private fixture error body stays hidden. All API requests are GET and no page errors occurred."
]
}
@@ -0,0 +1,40 @@
{
"version": 1,
"base_sha": "ba8b9fcd32f4f4d486e33d952ab09ecdc3d3b8a7",
"verified_at": "2026-10-04T04:10:01.032Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/diskListModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/diskListModel.ts": "1afb2fa866a8c4772e56ce38861610705d43a619537c6d57e0a79b95d288394b"
},
"routes": [
"/browser-tests/filesystem-usage-evidence.html"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 360,
"height": 844
}
],
"states": [
"Production DiskList and GuestDrawerOverview with actual CSS; 57 synthetic states across desktop Chromium, phone Chromium and dark phone WebKit. Playwright 1.56.1 matches the integration lock.",
"Missing, negative, non-finite and sentinel usage/capacity plus overflow never render a healthy zero, invalid percentage or numeric progress. Known capacity, mount identity and type remain visible.",
"Explicit measured zero, ordinary bytes, omitted redundant usage, over-capacity and valid retained values preserve their labels and capped visual fill. Twelve final bitmap contents were inspected; eight phone bitmaps match already opened captures exactly.",
"No native collection, backup thaw, installed recovery, physical phone, publication or release acceptance; full types/build qualification remains unavailable."
],
"interactions": [
"Replaced each same-filesystem synthetic observation through the fixture select without remounting either production panel; list/Overview agree on unavailable versus measured values.",
"Real fixture keyboard Home/Enter restores a live observation after unavailable. There is no new product action, route, tab, network call or collector change.",
"No page errors, API requests or off-origin requests; unknown-state layout fits the document, and full-width drawer copy remains readable."
]
}
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "53cfdf6020f91f14981007e9723d666113d24c7b",
"verified_at": "2026-10-04T05:28:12.394Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/useGuestRowState.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/utils/resourceStateAdapters.ts"
],
"content_sha256": {
"frontend-modern/src/hooks/useWorkloads.ts": "40833d2d47d7a8a41646a47568d92f1df375bc252e37d70d5303e22889441ce0",
"frontend-modern/src/components/Workloads/useGuestRowState.ts": "ef0e31343f20759c3d628653179fcfb424fa5e9e255f37643114714222215729",
"frontend-modern/src/utils/resourceStateAdapters.ts": "9ba757addef70fff50d0fe00accd06f3ef8ee44a86ce2b88de7ab93478b2d09a"
},
"routes": [
"/browser-tests/workload-read-evidence.html?transport=api",
"/browser-tests/workload-read-evidence.html?transport=canonical-full",
"/browser-tests/workload-read-evidence.html?transport=canonical-delta",
"/browser-tests/workload-read-evidence.html?transport=canonical-fast"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"40 same-VM synthetic read transitions through actual useWorkloads, canonical full/delta/fast merges and production GuestRow/GuestDrawer with real CSS; desktop Chromium and dark phone WebKit, Playwright 1.56.1 matches integration lock.",
"Retained lock/deferred readings remain visibly prior; lock clearance alone does not restore a current reading. New explicit native outcomes clear obsolete omitted optional fields even when numeric readings are unchanged.",
"Non-retained and unavailable read states have no numeric bar, animated numeric label, current History disk value or invented chart point. Explicit false is forwarded, measured zero remains current, and History no-data stays empty.",
"Six final screenshots inspected. Superseded script-only pass showed an adverse retained green bar; mounted source controls and no-bar browser assertions now cover it. Number text and aria target converge before final captures.",
"This is not native guest-agent/freeze/thaw, workload liveness, installed recovery, physical-phone, publication or release acceptance."
],
"interactions": [
"Same-identity API refetch and full/delta/fast snapshot replacement preserve row cell and active History DOM. Real Overview/History tab activation uses desktop mouse and phone touch.",
"Owned canonical snapshots issue no second workload-inventory GET. All fixture API traffic is local and GET-only, no off-origin request/page error and no guest/backup command.",
"Labels and safety notices fit the narrow viewport; measured-zero numeric text is checked at the final animation value, not an intermediate frame."
]
}
+76 -31
View File
@@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \
## 🔑 API Tokens
API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**.
API tokens provide scoped, revocable access to Pulse. Manage them in
**Settings → API Access**, in the **Security** group. An account without token
management permission can review the inventory but cannot create or revoke tokens.
The token shown during first-run setup is the primary automation API token for
that Pulse instance. It is separate from your web login password and is meant
for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens
are shown once; later token rows show only identifying hints such as prefix,
suffix, label, scopes, and last-used metadata.
that Pulse instance, not your web login password. Do not reuse a full-access
setup token for every consumer. Give each agent, script, integration or display
only the scopes it needs, with a name that identifies its use. Tokens are shown
once; later rows show hints and metadata, not a recoverable secret. Store the
secret privately before dismissing it.
Revoking a token is safe for Pulse itself, but it immediately breaks any agent,
script, kiosk, or integration still using that token. When a consumer needs to
stay online, create and install a replacement token first, then revoke the old
one. An agent whose token has been revoked stops authenticating until it is
reinstalled or reconfigured with a valid token.
### Replace or revoke a token
Revoking a token stops consumers still using it from authenticating. For a
planned rotation, create a least-privilege replacement, install it privately
in each affected consumer and verify a fresh authenticated result before
revoking the old token. Last-used metadata alone does not account for every
consumer. Do not reinstall an agent or delete its saved identity just to
replace a credential; use its private token-file or managed configuration.
**If a token or kiosk link has been exposed, revoke it promptly**, even if that
interrupts monitoring or a display. Replace it through the same private setup
path. Do not leave a leaked token active while arranging a gradual rotation.
For a server-address change without credential exposure, follow
[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead.
Never put a token or session cookie in a diagnostic command, screenshot or
issue thread. For scripted API access, use the
[private header-file procedure](API.md#api-token-recommended), keep TLS
verification enabled and do not share verbose or trace output.
### Token Scopes
@@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token.
| ------- | ------------- |
| `*` (Full access) | All permissions (legacy, not recommended) |
| `monitoring:read` | View dashboards, metrics, alerts |
| `monitoring:write` | Acknowledge/silence alerts |
| `monitoring:write` | Acknowledge, silence and clear alerts |
| `docker:report` | Docker / Podman agent telemetry submission |
| `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) |
| `kubernetes:report` | Kubernetes agent telemetry submission |
| `kubernetes:manage` | Kubernetes cluster management |
| `agent:report` | Agent host telemetry submission |
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Manage registered agents (unlink/delete/config) |
| `agent:exec` | Establish agent command WebSocket connections |
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration |
| `settings:write` | Modify configuration, manage tokens and trigger updates |
| `audit:read` | Read audit events, verification results, summaries, and exports |
### Presets
The UI offers quick presets for common use cases:
Use **New token**, enter a name under **Create token**, select a **Quick preset**
and choose **Generate**. The current presets are:
| Preset | Scopes | Use Case |
| -------- | -------- | ---------- |
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup |
| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays |
| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration |
| **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) |
| **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions |
| **Settings read** | `settings:read` | Read-only config access |
| **Settings admin** | `settings:read`, `settings:write` | Full config access |
| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports |
**Reporting is not remote execution.** The Agent preset does not grant
`agent:manage` or `agent:exec`; Docker / Podman report does not grant
`docker:manage`. Add lifecycle or action authority only for the operation you
intend, not to fix a missing reading. A settings-read token can read sensitive
configuration and diagnostics; it is not a public-display credential. See
[agent security](AGENT_SECURITY.md) for the separate collector and action-runner
requirements.
If **Patrol external agent** is offered, its scopes come from the current Patrol
requirements. Use the displayed set rather than guessing or adding **Full access**.
The separate **Full access** choice grants the legacy `*` wildcard, not a
least-privilege preset. Hiding controls does not reduce a token's permissions.
### Kiosk Mode
For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues:
For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only
`monitoring:read`. Never use **Full access**, a settings token or your
administrator's logged-in browser profile on an unattended display.
1. Go to **Settings → Security → API Tokens**
2. Click **New token** and select the **Kiosk / Dashboard** preset
3. Copy the generated token
4. Access Pulse via URL with token:
```text
https://your-pulse-url/?token=YOUR_TOKEN_HERE
```
1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**.
2. Use **New token**, name the display, select **Kiosk / Monitoring** and
choose **Generate**. Store the secret privately.
3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers
**Copy Link** for this monitoring-only token. Transfer that link privately
to the display; do not put it in shared bookmarks, screenshots or reports.
4. Check the display using the link in a separate browser profile with no
administrator session. Confirm it shows the intended monitoring data and
does not offer settings or write actions. A hidden control alone is not
proof of restricted access.
**Kiosk tokens:**
- Grant read-only dashboard access (`monitoring:read` scope)
- Hide the Settings tab automatically
- Work without cookies (token in URL)
- Can be revoked anytime from the UI
The link is a **bearer credential**: anyone with it can read the permitted
infrastructure data until the token expires or is revoked. The `kiosk=1`
option only hides navigation and filters; it does not authenticate a browser
or change the token's scopes.
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
Pulse removes the token from the page URL after loading and keeps it in that
tab's session storage. **The initial request already carried the token**;
HTTPS and the cleaned address bar do not remove copies from proxy/server logs,
browser history or the clipboard. Do not rely on this session surviving a
closed tab, browser restart or cleared site data. Keep the original link private
if the display needs it again, and clear clipboard copies after setup.
If the display or link is lost or exposed, revoke its dedicated token in
**API Access** and create a replacement. Revoking a display token should not
require rotating unrelated agents' credentials.
---
+138 -39
View File
@@ -193,40 +193,51 @@ sudo bash install.sh --version "${PULSE_VERSION}"
> **Note**: This installs the Pulse server. Use the `/install.sh` endpoint from **Settings → Infrastructure → Install on a host** for installing or upgrading `pulse-agent` on monitored hosts.
<details>
<summary><strong>Manual systemd install (advanced)</strong></summary>
<summary><strong>Manual or custom systemd services (advanced)</strong></summary>
For a **new Pulse server**, use the signed installer above rather than copying
one binary and writing a minimal service unit. The installer creates the
`pulse` service account, prepares the release tree and data ownership, and
installs the server service and update assets together. Its server unit uses
`User=pulse`, `Group=pulse`, `NoNewPrivileges=true`, `PrivateTmp=true`,
`ProtectSystem=strict` and `ProtectHome=true`, with writes limited to the
installation and data directories. Pulse's server does not need a root service;
the separate host **agent** has different privilege requirements (see
[Agent Security](AGENT_SECURITY.md)).
For an **existing manual or custom installation**, first inspect its effective
service settings locally. This read-only command does not print environment
values or credentials:
```bash
# Download and extract the architecture-specific tarball from GitHub Releases:
# https://github.com/rcourtman/Pulse/releases
# e.g.
# curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/pulse-${PULSE_VERSION}-linux-amd64.tar.gz"
# tar -xzf "pulse-${PULSE_VERSION}-linux-amd64.tar.gz"
# The extracted tree contains ./bin/pulse plus ./bin/pulse-agent-* and ./scripts/.
sudo install -m 0755 bin/pulse /usr/local/bin/pulse
# Create systemd service
sudo tee /etc/systemd/system/pulse.service > /dev/null << 'EOF'
[Unit]
Description=Pulse Monitoring
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/pulse
Restart=always
RestartSec=10
Environment=PULSE_DATA_DIR=/etc/pulse
[Install]
WantedBy=multi-user.target
EOF
# Start service
sudo mkdir -p /etc/pulse
sudo systemctl daemon-reload
sudo systemctl enable --now pulse
systemctl show pulse.service \
--property=LoadState \
--property=User \
--property=Group \
--property=NoNewPrivileges \
--property=PrivateTmp \
--property=ProtectSystem \
--property=ProtectHome
```
Use your actual server unit name if it differs; older installations may use
`pulse-backend.service`. Interpret these settings only when
`LoadState=loaded`; `LoadState=not-found` means the selected unit is missing,
not that a root service is running. For a loaded service, an empty `User=`
means systemd runs it as root. Missing hardening is not repaired just by
downloading a new binary:
**the installer preserves existing service units during updates**, including
custom units.
Do not overwrite a working unit, change only its service user, or reinstall over
an existing data directory to match these settings. Before a migration, follow
[the recovery guide](RECOVERY.md), preserve the data directory and its encryption
key, and record the active data path and deployment-managed configuration
privately. Check that the intended service account can access that state and
that required integrations work with the chosen sandbox. Do not loosen data
permissions, remove authentication or share a full unit/environment dump as a
shortcut.
</details>
---
@@ -338,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit.
## 🗑️ Uninstall
**Docker**:
Removing or stopping the Pulse server also stops monitoring and alert delivery.
This is not an update, rollback or password-reset procedure. Keep persistent
data by default; deleting it erases configuration, history, credentials and
the keys needed to decrypt that installation's data.
Before removing anything, identify the actual service, container or Helm
release and **every effective data path** privately. Keep a consistent,
private [full-state backup](MIGRATION.md#full-state-recovery), including the
matching encryption key and deployment configuration. A configuration export
alone is not a full backup. Let any in-progress Pulse update finish before
removing its installation. Do not post backups, environment files or full
container inspections in an issue.
### Docker and Compose: retain the data mount
The commands below assume `/data` is on a persistent named volume or bind
mount, as in this guide's examples. **Do not use them for state stored only in
the container's writable layer or temporary storage until it has a consistent
backup.** A container created with `--rm` can also delete anonymous volumes
when stopped. Container removal is not a backup.
For the `docker run` example, stop the container normally before removing it:
```bash
docker rm -f pulse && docker volume rm pulse_data
docker stop pulse
docker rm pulse
```
**Kubernetes**:
For Compose, run this from the **existing** project, using its actual Pulse
service name:
```bash
helm uninstall pulse -n pulse
docker compose stop pulse
docker compose rm pulse
```
**Systemd**:
These commands do not request volume deletion. Keep the named volume or bind
directory, original image/runtime and deployment settings; a reinstall must
reattach the **same** data mount. Compose normally prefixes volume names with
its project name, so creating a new project or an empty `pulse_data` volume
can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run
volume pruning as part of a data-preserving removal.
### Kubernetes: check claim ownership before uninstalling
Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse
chart creates a PersistentVolumeClaim without a keep policy; Helm removal can
delete that claim, and the storage reclaim policy can delete its backing data.
`persistence.existingClaim` refers to a separately managed claim, whose
lifecycle must be checked separately. With `persistence.enabled=false`, the
chart uses temporary `emptyDir` storage, lost when the pod is removed.
After verifying persistent storage and its backup, you can stop the default
deployment without uninstalling the chart:
```bash
sudo systemctl disable --now pulse
sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse
sudo systemctl daemon-reload
kubectl scale deployment pulse \
--namespace pulse \
--replicas=0
```
Use the actual deployment and namespace. Record the previous replica count
and suspend any controller that would recreate pods. Before permanently
uninstalling, verify the live claim's ownership, retention and reclaim policy
and test recovery from the private backup; do not delete a PVC or namespace
as a troubleshooting step.
### Systemd / Proxmox LXC: disable without erasing data
Identify the active service and any updater first; legacy installs can use
`pulse-backend`, and custom installs can have other names. In an LXC, run
these steps **inside the Pulse container**, not on the Proxmox host.
For the default signed installation, disable its update timer **if present**:
```bash
sudo systemctl disable --now \
pulse-update.timer
```
Then stop and disable the server:
```bash
sudo systemctl disable --now \
pulse.service
```
Disable other deployment-managed updaters through their owner too. These
commands leave the binary, service account, units and data in place; they
disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom
data directory), its keys and authentication sources together. Do not remove
the service account while retained files still need its ownership.
**Complete removal is a separate, destructive choice.** The signed server
installer's `--uninstall` deletes its configuration/data directory without a
keep-data prompt; it is not a data-preserving alternative. Use it only after
checking the effective install/config paths and a tested private backup, when
you intend that erasure. Do not delete `/bin/update` unless you have verified
it belongs to Pulse; community-scripts containers can use a different helper.
Removing the server does not remove agents on monitored hosts. Use each
agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or
[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures),
so it does not keep retrying the absent server.
+19 -10
View File
@@ -9,8 +9,13 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR
are listed under **Platform connections**.
3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the
username that owns the key.
4. Click **Test Connection** → **Save**.
5. Data appears within one configured polling cycle (60 seconds by default).
4. Click **Test Connection**, then **Save**. The test reads system information
on a separate connection; it does not validate inventory or metric collection.
5. Leave the saved connection enabled and allow at least one configured polling
interval (60 seconds by default). Check that the appliance and expected
inventory appear. If they do not, follow the [polling checks](#stale-truenas-data)
without repeatedly testing or restarting; an elapsed interval is not proof
that a poll completed.
## Creating a TrueNAS API Key
@@ -155,9 +160,11 @@ its trusted HTTPS origin without disabling certificate verification.
### No data appearing after adding connection
- Allow one configured polling cycle (60 seconds by default), not a fixed
30-second wait. Check that the saved connection is enabled.
- **Test Connection** checks authentication and the selected transport, not
successful collection of every metric. The legacy REST diagnostic is
expected for recognized CORE 13 systems; it is not itself a connection error.
- **Test Connection** reads system information on a separate connection. Pools,
datasets, disks and alerts can still fail during ordinary polling, even when
the test succeeds. A successful test also does not establish live CPU, memory
or History readings. The legacy REST diagnostic is expected for recognized
CORE 13 systems; it is not itself a connection error.
- Inspect a bounded local log excerpt for TrueNAS-related errors:
```bash
journalctl -u pulse -n 100 --no-pager | grep -i truenas
@@ -236,11 +243,13 @@ collection step.
| No timestamps advance, or no completed attempt is recorded | Check that the connection and integration are enabled, then preserve the bounded local error above. This is not proof of an invalid key. |
| Reconnects or TrueNAS sign-ins increase while `observed.collectedAt` advances | Session turnover alone does not establish failed authentication or stopped polling. Record the timing and any existing close/error message separately from the data freshness. |
**Test Connection** checks connectivity and authentication, not a complete
poll. Testing a saved connection can update `lastAttemptAt` and `lastSuccessAt`
and reset its failure count without refreshing `observed.collectedAt`. Record
any manual test and its time separately; a successful test is not evidence
that missing data or stopped polling has recovered.
**Test Connection** is not a complete poll. Some older builds, including Pulse
6.4.1 and 6.4.5, also let a saved-connection test update `lastAttemptAt` and
`lastSuccessAt` and reset its failure count without refreshing
`observed.collectedAt`. Record any manual test and its time separately, and
use the inventory observation time rather than the test time to check freshness.
A successful test is not evidence that missing data or stopped polling has
recovered.
An `app.stats` error such as **Apps are not available** concerns Apps
collection, not necessarily the key. Check the existing Apps status in
@@ -220,6 +220,59 @@ afterEach(() => {
// ── Tests ──────────────────────────────────────────────────────────────
describe('GuestDrawer', () => {
it('keeps deferred filesystem fallback last-known through History and fresh resumption', async () => {
chartsApiMocks.getMetricsHistory.mockResolvedValue({
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
start: 1,
end: 3,
metrics: {},
source: 'store',
});
const [guest, setGuest] = createSignal(
makeGuest({
disk: { total: 100, used: 50, usage: 50 },
diskStatusReason: 'prev-vm-locked',
lock: 'backup',
}),
);
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
const history = screen.getAllByTestId('guest-history-group-chart')[0];
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
'Disk50.0%last known',
);
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
'Guest reads paused while Proxmox reports a VM operation lock',
);
setGuest({ ...guest(), lock: '', diskStatusReason: 'prev-agent-busy' });
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
'earlier guest request is still in progress',
);
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
setGuest({ ...guest(), diskStatusReason: '', disk: { total: 100, used: 75, usage: 75 } });
expect(history.querySelector('[data-history-last-known]')).toBeNull();
expect(history.querySelector('[data-history-deferred]')).toBeNull();
expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent(
'Disk75.0%current',
);
expect(history.querySelector('path')).toBeNull();
// Existing drawer target recomputation follows each guest snapshot.
// This presentation repair does not change that request ownership.
for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) {
expect(request).toMatchObject({
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
});
}
});
it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => {
expect(guestDrawerSource).toContain('DiscoveryLoadingFallback');
expect(guestDrawerSource).not.toContain(
@@ -6,7 +6,11 @@ import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessB
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getGuestDrawerCurrentMetrics, type GuestDrawerProps } from './guestDrawerModel';
import {
getGuestDrawerCurrentMetrics,
getGuestDrawerDeferredMetrics,
type GuestDrawerProps,
} from './guestDrawerModel';
import { useGuestDrawerState } from './useGuestDrawerState';
import { GuestDrawerHistory, GuestDrawerHistoryRangeSelect } from './GuestDrawerHistory';
import { GuestDrawerOverview } from './GuestDrawerOverview';
@@ -49,6 +53,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
} = useGuestDrawerState(props);
const headingId = () => `guest-drawer-heading-${guestId()}`;
const historyCurrentMetrics = createMemo(() => getGuestDrawerCurrentMetrics(props.guest));
const historyDeferredMetrics = createMemo(() => getGuestDrawerDeferredMetrics(props.guest));
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.guest.status));
@@ -132,6 +137,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
target={historyTarget()}
range={historyRange()}
currentMetrics={historyCurrentMetrics()}
deferredMetrics={historyDeferredMetrics()}
/>
</div>
)}
@@ -41,12 +41,14 @@ import {
getGuestDrawerHistoryValueLabel,
normalizeGuestDrawerHistoryPoints,
type GuestDrawerHistoryGroupConfig,
type GuestDrawerHistoryDeferredMetric,
type GuestDrawerHistoryTarget,
type GuestDrawerHistoryTimeBounds,
} from './guestDrawerModel';
interface GuestDrawerHistoryProps {
currentMetrics?: Record<string, number | null | undefined>;
deferredMetrics?: Record<string, GuestDrawerHistoryDeferredMetric | undefined>;
groups?: GuestDrawerHistoryGroupConfig[];
range: HistoryTimeRange;
target: GuestDrawerHistoryTarget | null;
@@ -65,6 +67,7 @@ interface GuestDrawerHistoryQueryKey {
interface GuestDrawerHistoryGroupChartProps {
currentMetrics?: Record<string, number | null | undefined>;
deferredMetrics?: Record<string, GuestDrawerHistoryDeferredMetric | undefined>;
group: GuestDrawerHistoryGroupConfig;
loading: boolean;
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
@@ -309,24 +312,31 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
return byMetric;
});
const displaySeries = createMemo(() =>
series().map((item) => {
series().map((item, index) => {
const hovered = hoveredByMetric().get(item.metric);
const currentValue = props.currentMetrics?.[item.metric];
const deferred = props.deferredMetrics?.[item.metric];
const fallbackValue = deferred
? deferred.lastKnownValue
: props.currentMetrics?.[item.metric];
const isFallback =
activeTimestamp() === null &&
item.points.length === 0 &&
typeof fallbackValue === 'number' &&
Number.isFinite(fallbackValue);
return {
...item,
isCurrent:
activeTimestamp() === null &&
item.points.length === 0 &&
typeof currentValue === 'number' &&
Number.isFinite(currentValue),
deferred,
descriptionId: `${inspectionId}-deferred-${index}`,
isCurrent: isFallback && !deferred,
isLastKnown: isFallback && Boolean(deferred),
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: activeTimestamp() !== null
? '-'
: item.points.length > 0
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
: typeof currentValue === 'number' && Number.isFinite(currentValue)
? formatHistoryChartTooltipValue(currentValue, item.unit)
: typeof fallbackValue === 'number' && Number.isFinite(fallbackValue)
? formatHistoryChartTooltipValue(fallbackValue, item.unit)
: '-',
};
}),
@@ -382,6 +392,8 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<span
class="inline-flex items-center gap-1"
data-history-current={item.isCurrent ? item.metric : undefined}
data-history-last-known={item.isLastKnown ? item.metric : undefined}
aria-describedby={item.deferred ? item.descriptionId : undefined}
>
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" fill={item.color} />
@@ -391,12 +403,27 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<Show when={item.isCurrent}>
<span>current</span>
</Show>
<Show when={item.isLastKnown}>
<span>last known</span>
</Show>
</span>
)}
</For>
</div>
</div>
<For each={displaySeries().filter((item) => item.deferred)}>
{(item) => (
<p
id={item.descriptionId}
class="mb-2 text-xs leading-relaxed text-muted"
data-history-deferred={item.metric}
>
{item.label} live reading: {item.deferred?.message}
</p>
)}
</For>
<div class="relative min-h-24 flex-1">
<For each={[0, 0.5, 1]}>
{(tick) => (
@@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
metrics={metrics()}
currentMetrics={props.currentMetrics}
deferredMetrics={props.deferredMetrics}
range={props.range}
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
timeBounds={timeBounds()}
@@ -168,6 +168,27 @@ describe('DiskList', () => {
});
describe('usage calculation', () => {
it('withdraws one incomplete filesystem reading without losing another mount or inventing zero', () => {
const stable = makeDisk({ mountpoint: '/stable', used: 26843545600 });
const changing = makeDisk({ mountpoint: '/changing', device: '/dev/sda2' });
const [disks, update] = createSignal([stable, changing]);
render(() => <DiskList disks={disks()} />);
expect(screen.getByText('25%')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
update([stable, { ...changing, used: undefined }]);
expect(screen.getByText('25%')).toBeInTheDocument();
expect(screen.getByText('/changing').parentElement).toHaveTextContent('?/100 GB');
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
expect(screen.queryByText('50%')).not.toBeInTheDocument();
update([stable, { ...changing, used: 0, usage: undefined }]);
expect(screen.getByText('25%')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.queryByText('—')).not.toBeInTheDocument();
});
it('derives usage percent through the canonical disk-list model', () => {
const disk = makeDisk({ used: 26843545600, total: 107374182400 });
expect(getWorkloadsDiskUsagePercent(disk)).toBe(25);
@@ -0,0 +1,261 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import type { WorkloadGuest } from '@/types/workloads';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
import { getGuestDrawerCurrentMetrics, getGuestDrawerDeferredMetrics } from '../guestDrawerModel';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 90,
isRangeLocked: () => false,
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
const guest = (reason: string, usage = 50): WorkloadGuest => ({
id: 'fixture:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture',
type: 'qemu',
status: 'running',
cpu: 0.1,
cpus: 2,
memory: { total: 100, used: 25, free: 75, usage: 25 },
disk: { total: 100, used: usage, usage },
networkIn: 100,
networkOut: 200,
diskRead: 300,
diskWrite: 400,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: '',
diskStatusReason: reason,
lastSeen: '2026-10-04T03:00:00Z',
});
const start = Date.UTC(2026, 9, 4, 2);
const end = start + 60 * 60_000;
const response = (
metrics: AllMetricsHistoryResponse['metrics'] = {},
): AllMetricsHistoryResponse => ({
resourceType: 'vm',
resourceId: 'fixture:pve-a:101',
range: '1h',
start,
end,
metrics,
source: 'store',
});
const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value });
const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve-a:101' };
const mount = (value: () => WorkloadGuest) =>
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
currentMetrics={getGuestDrawerCurrentMetrics(value())}
deferredMetrics={getGuestDrawerDeferredMetrics(value())}
/>
));
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
// Actual backend provenance remains authoritative. Removing a VM lock alone
// cannot turn retained readings into current data or prove native thaw.
describe('GuestDrawerHistory filesystem provenance', () => {
it.each(guestDiskDeferrals)(
'labels retained %s as last known without creating a stored observation',
async (reason, message) => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount(() => guest(`prev-${reason}`));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
const chart = utilization();
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
const retained = chart.querySelector('[data-history-last-known="disk"]')!;
expect(retained).toHaveTextContent('Disk50.0%last known');
expect(retained).toHaveAccessibleDescription(
`Disk live reading: Using last known disk stats. ${message}`,
);
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
'CPU10.0%current',
);
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
'Memory25.0%current',
);
expect(chart.querySelector('path')).toBeNull();
expect(chart.querySelector('[data-history-observation]')).toBeNull();
expect(fetch).toHaveBeenCalledTimes(1);
},
);
it.each(guestDiskDeferrals)(
'shows unavailable %s without promoting an unretained numeric summary',
async (reason, message) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount(() => guest(reason));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
const chart = utilization();
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
expect(chart).toHaveTextContent('Disk-');
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(message);
},
);
it.each([-1, NaN, Infinity, undefined])(
'does not display unusable retained disk usage %s',
(usage) => {
const value = guest('prev-vm-locked');
value.disk.usage = usage as number;
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(value).disk.message).toContain('Guest reads paused');
},
);
it('keeps retained zero valid and honours explicit disk telemetry unavailability', () => {
const value = guest('prev-agent-busy', 0);
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBe(0);
value.telemetryAvailability = {
cpu: true,
memory: true,
disk: false,
networkIO: true,
diskIO: true,
uptime: true,
};
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({
cpu: 10,
memory: 25,
diskread: 300,
diskwrite: 400,
});
});
it('keeps non-VM data independent and maps unknown reason text without exposing it', () => {
for (const type of ['lxc', 'app-container'] as const) {
const value = { ...guest('prev-vm-locked'), type };
expect(getGuestDrawerDeferredMetrics(value)).toEqual({});
expect(getGuestDrawerCurrentMetrics(value).disk).toBe(50);
}
const value = guest('private transport error');
expect(getGuestDrawerDeferredMetrics(value).disk.message).toBe(
'Disk stats unavailable. Guest agent may not be installed.',
);
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
});
it('keeps real stored points and dated keyboard inspection separate from live deferral', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
disk: [point(start, 20), point(end, 30)],
cpu: [point(start, 10), point(end, 15)],
}),
);
mount(() => guest('prev-agent-timeout'));
await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(2));
const chart = utilization();
expect(chart).toHaveTextContent('Disk30.0%');
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
'Completion is uncertain. Do not restart the guest agent during a backup.',
);
const slider = within(chart).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(slider).toHaveAttribute(
'aria-valuetext',
`${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk 20.0%.`,
);
expect(chart).toHaveTextContent('Disk20.0%');
expect(chart.querySelector('[data-history-current]')).toBeNull();
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
expect(fetch).toHaveBeenCalledTimes(1);
});
it("does not borrow retained live disk evidence during another metric's dated inspection", async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({ cpu: [point(start, 10), point(end, 15)] }),
);
mount(() => guest('prev-vm-locked'));
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
const slider = screen.getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(utilization()).toHaveTextContent('Disk-');
expect(utilization().querySelector('[data-history-last-known]')).toBeNull();
expect(slider).toHaveAttribute(
'aria-valuetext',
expect.stringContaining('Disk no observation.'),
);
fireEvent.blur(slider);
expect(utilization().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
'50.0%last known',
);
});
it('withdraws retained context only with fresh same-source data, without refetch or remount', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
const [value, setValue] = createSignal(guest('prev-vm-locked'));
mount(value);
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
const chart = utilization();
setValue({ ...value(), lock: '', diskStatusReason: 'prev-agent-busy' });
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
'still in progress',
);
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
setValue(guest('', 75));
expect(utilization()).toBe(chart);
expect(chart.querySelector('[data-history-deferred]')).toBeNull();
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent('75.0%current');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not reveal retained evidence through denied History', async () => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockRejectedValueOnce(Object.assign(new Error('private refusal'), { status: 403 }))
.mockResolvedValueOnce(response());
mount(() => guest('prev-vm-locked'));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
'aria-busy',
'false',
),
);
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(),
);
expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument();
expect(document.querySelector('[data-history-deferred]')).toBeNull();
expect(screen.queryByText('private refusal')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
await waitFor(() =>
expect(utilization().querySelector('[data-history-last-known]')).not.toBeNull(),
);
expect(fetch).toHaveBeenCalledTimes(3);
});
});
@@ -49,6 +49,30 @@ const group = (id = 'utilization') =>
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
it('gives explicit deferred evidence precedence over a numeric current fallback', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
currentMetrics={{ disk: 99 }}
deferredMetrics={{ disk: { lastKnownValue: 50, message: 'Guest reads paused.' } }}
/>
));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
expect(group().querySelector('[data-history-current]')).toBeNull();
expect(group().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
'Disk50.0%last known',
);
expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription(
'Disk live reading: Guest reads paused.',
);
expect(group().querySelector('path')).toBeNull();
expect(dots()).toHaveLength(0);
});
it.each([
['cpu', 'utilization', 'CPU 0.0%'],
['netin', 'network', 'In 0 B/s'],
@@ -688,6 +688,58 @@ describe('GuestRow', () => {
};
for (const mode of ['bars', 'sparklines'] as const) {
it.each(guestDiskDeferrals)(
`withdraws stale numeric ${mode} when %s has no retained reading`,
(reason) => {
const { container } = renderGuestRow({
guest: makeGuest({ diskStatusReason: reason, disk: makeDisk({ usage: 50 }) }),
visibleColumnIds: ['name', 'disk'],
metricDisplayMode: mode,
});
expect(screen.queryByTestId('disk-bar')).toBeNull();
const cell = container.querySelector('[data-workload-col="disk"]')!;
expect(cell).toHaveTextContent('Unavailable');
const chart = cell.querySelector('[role="img"]');
if (chart) expect(chart.getAttribute('aria-label')).not.toMatch(/50%/);
},
);
it(`does not revive an unavailable canonical metric in ${mode}, preserving measured zero`, () => {
const [guest, setGuest] = createSignal(
makeGuest({
diskStatusReason: 'prev-vm-locked',
telemetryAvailability: {
cpu: true,
memory: true,
disk: false,
networkIO: true,
diskIO: true,
uptime: true,
},
}),
);
const { container } = render(() => (
<table>
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
metricDisplayMode={mode}
/>
</tbody>
</table>
));
expect(screen.queryByTestId('disk-bar')).toBeNull();
const cell = container.querySelector('[data-workload-col="disk"]')!;
const chart = cell.querySelector('[role="img"]');
if (chart) expect(chart.getAttribute('aria-label')).not.toMatch(/50%/);
setGuest(makeGuest({ disk: makeDisk({ used: 0, usage: 0 }) }));
expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell);
expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull();
if (mode === 'bars') expect(screen.getByTestId('disk-bar')).toBeTruthy();
else expect(cell.querySelector('[role="img"]')?.getAttribute('aria-label')).toMatch(/0%/);
});
it.each(guestDiskDeferrals)(
`labels retained ${mode} for %s without a hover`,
(reason, message) => {
@@ -60,8 +60,8 @@ describe('diskListModel (branch coverage 0713)', () => {
});
describe('getWorkloadsDiskUsagePercent', () => {
it('returns 0 when the disk has no capacity (false arm of the guard)', () => {
expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBe(0);
it('returns null when the disk has no capacity rather than a measured zero', () => {
expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBeNull();
});
it('returns (used / total) * 100 for a normal ratio (true arm of the guard)', () => {
@@ -142,8 +142,8 @@ describe('diskListModel (branch coverage 0713)', () => {
);
});
it('maps a no-capacity disk (percent 0) to the normal class', () => {
expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe(NORMAL_CLASS);
it('keeps a no-capacity disk neutral rather than classifying an unmeasured zero', () => {
expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe('bg-surface-hover');
});
it('falls back to the default disk thresholds when thresholds is null (nullish arm)', () => {
@@ -280,7 +280,7 @@ describe('diskListModel (branch coverage 0713)', () => {
key: '/foo:/dev/sdb:5',
label: '/foo',
labelTitle: '/foo',
progressClass: NORMAL_CLASS,
progressClass: 'bg-surface-hover',
progressValue: null,
progressWidth: '0%',
typeLabel: 'EXT4',
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import type { Disk, VM } from '@/types/api';
import { DiskList } from '../DiskList';
import { GuestDrawerOverview } from '../GuestDrawerOverview';
import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
const capacity = 10 * 1024 ** 3;
const disk = (overrides: Partial<Disk> = {}): Disk => ({
mountpoint: '/data',
type: 'ext4',
total: capacity,
used: capacity / 2,
usage: 50,
...overrides,
});
const unavailable: [string, Partial<Disk>][] = [
['missing used bytes', { used: undefined }],
['negative used bytes', { used: -1 }],
['NaN used bytes', { used: NaN }],
['infinite used bytes', { used: Infinity }],
['negative infinite used bytes', { used: -Infinity }],
['missing capacity', { total: undefined }],
['zero capacity', { total: 0 }],
['negative capacity', { total: -1 }],
['NaN capacity', { total: NaN }],
['infinite capacity', { total: Infinity }],
['negative infinite capacity', { total: -Infinity }],
['unknown usage sentinel', { usage: -1, used: 0 }],
['NaN usage', { usage: NaN }],
['infinite usage', { usage: Infinity }],
['negative infinite usage', { usage: -Infinity }],
['overflowing derived percentage', { total: 1, used: Number.MAX_VALUE }],
];
afterEach(cleanup);
describe('filesystem readings need measured bytes, not zero defaults', () => {
it.each(unavailable)('withdraws a numeric reading for %s', (_name, values) => {
const input = disk(values);
const presentation = buildWorkloadsDiskPresentation(input, 0);
expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
expect(presentation.progressValue).toBeNull();
expect(presentation.progressWidth).toBe('0%');
expect(presentation.progressClass).toBe('bg-surface-hover');
expect(presentation.usagePercentLabel).toBe('—');
expect(presentation.usageText).not.toMatch(/NaN|Infinity|^0 B\//);
expect(presentation.label).toBe('/data');
expect(presentation.typeLabel).toBe('EXT4');
});
it('retains known capacity without inventing used bytes from a percentage or free bytes', () => {
const input = disk({ used: undefined, usage: 50, free: capacity / 2 });
expect(buildWorkloadsDiskPresentation(input, 0).usageText).toBe('?/10.0 GB');
expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
});
it('keeps an explicit measured zero, including when the redundant usage field is omitted', () => {
const presentation = buildWorkloadsDiskPresentation(disk({ used: 0, usage: undefined }), 0);
expect(presentation.progressValue).toBe(0);
expect(presentation.usagePercentLabel).toBe('0%');
expect(presentation.usageText).toBe('0 B/10.0 GB');
expect(presentation.progressClass).toContain('metric-normal');
});
it('derives ordinary and over-capacity percentages from the measured bytes', () => {
const input = disk({ used: capacity * 1.25, usage: 0 });
const presentation = buildWorkloadsDiskPresentation(input, 0);
expect(getWorkloadsDiskUsagePercent(disk({ usage: undefined }))).toBe(50);
expect(presentation.progressValue).toBe(125);
expect(presentation.usagePercentLabel).toBe('125%');
expect(presentation.progressWidth).toBe('100%');
expect(presentation.progressClass).toContain('metric-critical');
});
});
function renderPanels(initial: Disk) {
const [observation, setObservation] = createSignal(initial);
const guest = (): VM => ({
id: 'fixture:pve-a:101',
vmid: 101,
node: 'pve-a',
instance: 'fixture',
name: 'filesystem-evidence',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
disk: observation(),
disks: [observation()],
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: '',
lastSeen: '2026-10-04T04:00:00Z',
});
render(() => (
<>
<section data-testid="disk-list-panel">
<DiskList disks={[observation()]} />
</section>
<GuestDrawerOverview
guest={guest()}
guestOsSummary=""
agentHeading="Guest agent"
agentLabel=""
agentTitle=""
hasAgentInfo={false}
hasFilesystemDetails={true}
hasNetworkInterfaces={false}
hasOsInfo={false}
hasWorkloadActionAgent={false}
showInGuestAgentInstallCue={false}
ipAddresses={[]}
networkInterfaces={[]}
normalizedTags={[]}
backupPresentation={null}
workloadActionAgentTitle=""
/>
</>
));
return setObservation;
}
describe('both filesystem panels preserve unavailable versus measured zero', () => {
it.each([
['missing used bytes', { used: undefined }],
['invalid used bytes', { used: NaN }],
['invalid capacity', { total: Infinity }],
['unknown usage sentinel', { usage: -1, used: 0 }],
] satisfies [string, Partial<Disk>][])(
'does not show an empty healthy disk for %s',
(_name, values) => {
renderPanels(disk(values));
for (const id of ['disk-list-panel', 'guest-technical-details']) {
const panel = screen.getByTestId(id);
expect(panel.textContent).toContain('—');
expect(panel.textContent).not.toMatch(/0%|NaN|Infinity/);
expect(within(panel).queryByRole('progressbar')).not.toBeInTheDocument();
}
},
);
it('withdraws a prior measurement and restores zero only after explicit same-filesystem evidence', () => {
const update = renderPanels(disk());
const details = screen.getByTestId('guest-technical-details');
expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
update(disk({ used: undefined }));
expect(within(details).queryByRole('progressbar')).not.toBeInTheDocument();
expect(details).toHaveTextContent('— · ?/10.0 GB · EXT4');
update(disk({ used: 0, usage: undefined }));
expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
expect(details).toHaveTextContent('0% · 0 B/10.0 GB · EXT4');
});
});
@@ -22,21 +22,32 @@ export interface WorkloadsDiskPresentation {
usagePercentLabel: string;
}
export const hasWorkloadsDiskCapacity = (disk: Disk): boolean =>
typeof disk.total === 'number' && disk.total > 0;
export const hasWorkloadsDiskCapacity = (disk: Disk): disk is Disk & { total: number } =>
typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0;
// The poller reports usage -1 for mounts it can only see in the container
// config (capacity may be known, live usage is not).
export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean =>
typeof disk.usage === 'number' && disk.usage < 0;
typeof disk.used !== 'number' ||
!Number.isFinite(disk.used) ||
disk.used < 0 ||
(typeof disk.usage === 'number' && (!Number.isFinite(disk.usage) || disk.usage < 0));
export const getWorkloadsDiskUsagePercent = (disk: Disk): number => {
const total = disk.total ?? 0;
if (total <= 0) {
return 0;
// Capacity alone is not a measurement. In particular, an omitted `used`
// field must not become a healthy empty filesystem, even if usage/free exist.
export const getWorkloadsDiskUsagePercent = (disk: Disk): number | null => {
const { total, used } = disk;
if (
!hasWorkloadsDiskCapacity(disk) ||
isWorkloadsDiskUsageUnknown(disk) ||
typeof total !== 'number' ||
typeof used !== 'number'
) {
return null;
}
return ((disk.used ?? 0) / total) * 100;
const percent = (used / total) * 100;
return Number.isFinite(percent) ? percent : null;
};
export const getWorkloadsDiskLabel = (disk: Disk): string =>
@@ -46,35 +57,34 @@ export const getWorkloadsDiskLabelTitle = (label: string): string | undefined =>
label !== 'Unknown' ? label : undefined;
export const getWorkloadsDiskUsageText = (disk: Disk): string => {
if (isWorkloadsDiskUsageUnknown(disk)) {
return hasWorkloadsDiskCapacity(disk)
? `?/${formatBytes(disk.total ?? 0)}`
: 'Usage unavailable';
}
return hasWorkloadsDiskCapacity(disk)
? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}`
: 'Usage unavailable';
if (!hasWorkloadsDiskCapacity(disk)) return 'Usage unavailable';
const used =
getWorkloadsDiskUsagePercent(disk) === null || typeof disk.used !== 'number'
? '?'
: formatBytes(disk.used);
return `${used}/${formatBytes(disk.total)}`;
};
export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string =>
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%`
: '—';
export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => {
const percent = getWorkloadsDiskUsagePercent(disk);
return percent === null ? '—' : `${percent.toFixed(0)}%`;
};
export const getWorkloadsDiskProgressClass = (
disk: Disk,
thresholds?: MetricDisplayThresholds | null,
): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
): string => {
const percent = getWorkloadsDiskUsagePercent(disk);
return percent === null ? 'bg-surface-hover' : getMetricColorClass(percent, 'disk', thresholds);
};
export const getWorkloadsDiskProgressValue = (disk: Disk): number | null =>
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
? getWorkloadsDiskUsagePercent(disk)
: null;
getWorkloadsDiskUsagePercent(disk);
export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
isWorkloadsDiskUsageUnknown(disk)
? '0%'
: `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
export const getWorkloadsDiskProgressWidth = (disk: Disk): string => {
const percent = getWorkloadsDiskUsagePercent(disk);
return percent === null ? '0%' : `${Math.min(percent, 100)}%`;
};
export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';
@@ -8,7 +8,10 @@ import type {
import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel';
import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format';
import { getWorkloadsGuestBackupStatusPresentation } from '@/utils/workloadGuestPresentation';
import {
getWorkloadGuestDiskStatusMessage,
getWorkloadsGuestBackupStatusPresentation,
} from '@/utils/workloadGuestPresentation';
import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
@@ -60,6 +63,11 @@ export interface GuestDrawerHistoryTimeBounds {
endTime: number;
}
export interface GuestDrawerHistoryDeferredMetric {
lastKnownValue?: number;
message: string;
}
export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
@@ -93,6 +101,25 @@ export const getGuestDrawerAlertMessage = (
return `${alert.message} (${comparison})`;
};
const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
const usage = guest.telemetryAvailability?.disk === false ? undefined : guest.disk?.usage;
return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
};
export const getGuestDrawerDeferredMetrics = (
guest: Guest,
): Record<string, GuestDrawerHistoryDeferredMetric> => {
if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {};
return {
disk: {
lastKnownValue: guest.diskStatusReason.startsWith('prev-')
? getGuestDrawerDiskUsage(guest)
: undefined,
message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
},
};
};
// Current-value metrics displayed beside history legends while the metrics
// store is still accumulating samples. These values never become chart points:
// a current reading is not evidence of a historical trend.
@@ -103,13 +130,10 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record<string, numbe
const cpuPercent = available('cpu') ? getWorkloadCPUPercent(guest.cpu) : undefined;
const memUsage =
available('memory') && !guest.memory?.usageUnavailable ? guest.memory?.usage : undefined;
const reportedDiskUsage = available('disk') ? guest.disk?.usage : undefined;
// A paused filesystem read is not current, even if the snapshot still
// carries a numeric summary. Retained evidence has its own labelled path.
const diskUsage =
typeof reportedDiskUsage === 'number' &&
Number.isFinite(reportedDiskUsage) &&
reportedDiskUsage >= 0
? reportedDiskUsage
: undefined;
isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
const finite = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return {
@@ -226,9 +226,22 @@ export function useGuestRowState(props: GuestRowProps) {
});
const hasDiskUsage = createMemo(() => {
if (!props.guest.disk) return false;
if ((props.guest.disk.total ?? 0) <= 0) return false;
return diskPercent() !== -1;
const guest = props.guest;
if (!guest.disk || guest.telemetryAvailability?.disk === false) return false;
const total = guest.disk.total ?? 0;
if (!Number.isFinite(total) || total <= 0) return false;
// A generic canonical merge can retain the previous numeric metric while
// the provider explicitly reports no usable guest reading. Only prev-
// reasons allow that number to remain visible as last-known evidence.
if (
workloadType() === 'vm' &&
'diskStatusReason' in guest &&
guest.diskStatusReason &&
!guest.diskStatusReason.startsWith('prev-')
)
return false;
const percent = diskPercent();
return Number.isFinite(percent) && percent >= 0;
});
const parentOnline = createMemo(() => props.parentNodeOnline !== false);
@@ -1,6 +1,8 @@
import { batch, createEffect, createRoot, createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useWorkloadsSource from '../useWorkloads.ts?raw';
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
import type { VM } from '@/types/api';
type UseWorkloadsModule = typeof import('@/hooks/useWorkloads');
@@ -124,6 +126,138 @@ describe('useWorkloads', () => {
vi.resetModules();
});
it.each(guestDiskDeferrals)(
'preserves %s and the native lock through the resources API',
async (reason) => {
apiFetchJSONMock.mockResolvedValueOnce({
data: [
{
...sampleResource,
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: 'cluster-a',
diskStatusReason: `prev-${reason}`,
guestAgentStatus: 'deferred',
guestAgentExpected: true,
lock: 'backup',
},
},
],
meta: { totalPages: 1 },
});
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']>;
createRoot((d) => {
dispose = d;
result = useWorkloads();
});
try {
await waitForWorkloadCount(() => result.workloads().length);
expect(result!.workloads()[0]).toMatchObject({
diskStatusReason: `prev-${reason}`,
guestAgentStatus: 'deferred',
guestAgentExpected: true,
lock: 'backup',
disk: { usage: 20 },
});
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
} finally {
dispose();
}
},
);
it.each(guestDiskDeferrals)(
'preserves %s through the owning Proxmox snapshot without another request',
async (reason) => {
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']>;
createRoot((d) => {
dispose = d;
result = useWorkloads(() => true, {
resourceSnapshot: () =>
[
{
...sampleResource,
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: 'cluster-a',
diskStatusReason: reason,
guestAgentStatus: 'deferred',
guestAgentExpected: false,
lock: 'migrate',
},
disk: { current: 20, used: 20 * 1024, total: 100 * 1024 },
},
] as any,
});
});
try {
await flushAsync();
expect(result!.workloads()[0]).toMatchObject({
diskStatusReason: reason,
guestAgentStatus: 'deferred',
guestAgentExpected: false,
lock: 'migrate',
disk: { usage: 20 },
});
expect(apiFetchJSONMock).not.toHaveBeenCalled();
} finally {
dispose();
}
},
);
it('updates read evidence even with unchanged metrics, preserving other site rows', async () => {
const buildVM = (instance: string, proxmox: Record<string, unknown> = {}, current = 20) =>
({
...sampleResource,
id: `${instance}-pve1-101`,
proxmox: { vmid: 101, nodeName: 'pve1', instance, ...proxmox },
disk: { current, used: current * 1024, total: 100 * 1024 },
}) as any;
const other = buildVM('cluster-b');
const [snapshot, setSnapshot] = createSignal([
buildVM('cluster-a', {
diskStatusReason: 'prev-vm-locked',
guestAgentStatus: 'deferred',
guestAgentExpected: true,
lock: 'backup',
}),
other,
]);
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']>;
createRoot((d) => {
dispose = d;
result = useWorkloads(() => true, { resourceSnapshot: snapshot });
});
const target = () => result!.workloads().find((row) => row.instance === 'cluster-a')! as VM;
try {
await flushAsync();
const prior = target();
const otherRow = result!.workloads().find((row) => row.instance === 'cluster-b');
expect(prior.diskStatusReason).toBe('prev-vm-locked');
setSnapshot([buildVM('cluster-a', { guestAgentStatus: 'available' }), other]);
await flushAsync();
expect(target()).not.toBe(prior);
expect(target().diskStatusReason).toBeUndefined();
expect(target().lock).toBe('');
expect(target().guestAgentStatus).toBe('available');
expect(target().guestAgentExpected).toBeUndefined();
expect(target().disk.usage).toBe(20);
expect(result!.workloads().find((row) => row.instance === 'cluster-b')).toBe(otherRow);
setSnapshot([buildVM('cluster-a', { guestAgentStatus: 'available' }, 0), other]);
await flushAsync();
expect(target().disk.usage).toBe(0);
expect(apiFetchJSONMock).not.toHaveBeenCalled();
} finally {
dispose();
}
});
it('reuses fresh cache on remount without an extra network fetch', async () => {
let disposeFirst = () => {};
createRoot((d) => {
+10 -2
View File
@@ -113,6 +113,10 @@ type APIResource = {
lastBackup?: string;
backupInProgress?: boolean;
disks?: APIDiskInfo[];
diskStatusReason?: string;
guestAgentStatus?: string;
guestAgentExpected?: boolean;
lock?: string;
swapUsed?: number;
swapTotal?: number;
balloon?: number;
@@ -569,7 +573,11 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
})(),
disk: buildMetric(resource.metrics?.disk),
disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks),
diskStatusReason: undefined,
// Both the API and owning platform snapshot pass through this mapper.
// A retained numeric metric must not lose the provider's read deferral.
diskStatusReason: resource.proxmox?.diskStatusReason,
guestAgentStatus: resource.proxmox?.guestAgentStatus,
guestAgentExpected: resource.proxmox?.guestAgentExpected,
ipAddresses: resource.identity?.ipAddresses ?? [],
// Guest OS info: agent.osName/osVersion is the universal fallback the
// workload table reads. Proxmox writes to resource.proxmox.osName /
@@ -606,7 +614,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
// cell, so the vSphere facet wins for every vSphere workload — including
// the untagged ones, which must render empty rather than fall back.
tags: vmwareRowTags(resource) ?? resource.tags ?? [],
lock: '',
lock: resource.proxmox?.lock ?? '',
lastSeen: toIsoString(resource.lastSeen),
isOci: workloadType === 'system-container' ? (resource.proxmox?.isOci ?? false) : false,
osTemplate: workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined,
@@ -61,6 +61,127 @@ function getRuntimeSourceFiles(dir: string): string[] {
}
describe('docsLinks', () => {
it('separates server removal from persistent-data erasure', () => {
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
const removal = installation.split('## 🗑️ Uninstall')[1];
expect(removal).toContain('stops monitoring and alert delivery');
expect(removal).toContain('Keep persistent\ndata by default');
expect(removal).toContain('every effective data path');
expect(removal).toContain('[full-state backup](MIGRATION.md#full-state-recovery)');
expect(removal).toContain('A configuration export\nalone is not a full backup');
expect(removal).toContain('Let any in-progress Pulse update finish');
expect(removal).toContain('without a\nkeep-data prompt');
expect(removal).toContain('Do not remove\nthe service account');
expect(removal).toContain('Do not delete `/bin/update` unless');
expect(removal).toContain('Removing the server does not remove agents');
// Pin the copyable recipes, not merely the surrounding warnings. The old
// Docker/root commands erased the complete data store as part of removal.
const commands = [...removal.matchAll(/```bash\n([\s\S]*?)```/g)].flatMap((match) =>
match[1]
.replace(/\\\n\s*/g, ' ')
.trim()
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim()),
);
expect(commands).toEqual([
'docker stop pulse',
'docker rm pulse',
'docker compose stop pulse',
'docker compose rm pulse',
'kubectl scale deployment pulse --namespace pulse --replicas=0',
'sudo systemctl disable --now pulse-update.timer',
'sudo systemctl disable --now pulse.service',
]);
});
it('bounds container-removal advice to retained persistent mounts', () => {
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
const docker = (
installation.split('### Docker and Compose: retain the data mount')[1] ?? ''
).split('### Kubernetes:')[0];
expect(docker).toContain('persistent named volume or bind');
expect(docker).toContain("container's writable layer or temporary storage");
expect(docker).toContain('`--rm` can also delete anonymous volumes');
expect(docker).toContain('**existing** project');
expect(docker).toContain('reattach the **same** data mount');
expect(docker).toContain('prefixes volume names');
expect(docker).toContain('Do not add `-v`/`--volumes`');
expect(docker).not.toContain('docker rm -f');
expect(docker).not.toContain('docker volume rm');
});
it('does not promise Helm retention where the current chart owns the claim', () => {
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
const kubernetes = (
installation.split('### Kubernetes: check claim ownership before uninstalling')[1] ?? ''
).split('### Systemd / Proxmox LXC:')[0];
expect(kubernetes).toContain('Do not assume `helm uninstall pulse -n pulse` retains data');
expect(kubernetes).toContain('without a keep policy');
expect(kubernetes).toContain('reclaim policy can delete its backing data');
expect(kubernetes).toContain('`persistence.existingClaim`');
expect(kubernetes).toContain('`emptyDir` storage, lost when the pod is removed');
expect(kubernetes.indexOf('After verifying persistent storage and its backup')).toBeLessThan(
kubernetes.indexOf('```bash'),
);
expect(kubernetes).toContain('suspend any controller');
const chart = readFileSync(
path.join(repoRoot, 'deploy', 'helm', 'pulse', 'templates', 'pvc.yaml'),
'utf8',
);
expect(chart).toContain('kind: PersistentVolumeClaim');
expect(chart).toContain('(not .Values.persistence.existingClaim)');
expect(chart).not.toContain('helm.sh/resource-policy');
const values = readFileSync(
path.join(repoRoot, 'deploy', 'helm', 'pulse', 'values.yaml'),
'utf8',
);
expect(values.split('persistence:')[1].split('server:')[0]).toContain('annotations: {}');
});
it('keeps custom systemd server help on the signed non-root install path', () => {
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
const custom = installation
.split('<summary><strong>Manual or custom systemd services (advanced)</strong></summary>')[1]
.split('</details>')[0];
expect(custom).toContain('use the signed installer above');
expect(custom).toContain('`User=pulse`, `Group=pulse`');
expect(custom).toContain('`ProtectSystem=strict` and `ProtectHome=true`');
expect(custom).toContain('`LoadState=loaded`');
expect(custom).toContain('not that a root service is running');
expect(custom).toContain('an empty `User=`');
expect(custom).toContain('the installer preserves existing service units during updates');
expect(custom).toContain('Do not overwrite a working unit, change only its service user');
expect(custom).toContain('preserve the data directory and its encryption');
expect(custom).toContain('separate host **agent** has different privilege requirements');
const commands = [...custom.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]);
expect(commands).toHaveLength(1);
expect(commands[0]).toContain('systemctl show pulse.service');
expect(commands[0]).toContain('--property=LoadState');
expect(Math.max(...commands[0].split('\n').map((line) => line.length))).toBeLessThan(34);
expect(commands[0]).not.toMatch(/--property=(Environment|ExecStart)|sudo|tee|install -m/);
expect(installation).not.toContain('sudo tee /etc/systemd/system/pulse.service');
expect(installation).not.toContain('ExecStart=/usr/local/bin/pulse');
});
it('keeps TrueNAS setup acceptance separate from a system-information probe', () => {
const truenas = readFileSync(path.join(repoRoot, 'docs', 'TRUENAS.md'), 'utf8');
const setup = truenas.split('## Quick Start')[1].split('## Creating a TrueNAS API Key')[0];
expect(setup).toContain('it does not validate inventory or metric collection');
expect(setup).toContain('an elapsed interval is not proof');
expect(setup).toContain('[polling checks](#stale-truenas-data)');
expect(setup).not.toContain('Data appears within one configured polling cycle');
const noData = truenas
.split('### No data appearing after adding connection')[1]
.split('### Inventory works')[0];
expect(noData).toContain('Pools,\n datasets, disks and alerts can still fail');
expect(noData).toContain('does not establish live CPU, memory');
expect(truenas).toContain('Some older builds, including Pulse\n6.4.1 and 6.4.5');
expect(truenas).toContain('use the inventory observation time rather than the test time');
expect(truenas).toContain('do not copy a token or cookie');
expect(truenas).toContain('Do not post the full connection response');
});
it('keeps restored Proxmox node network details in the candidate packet', () => {
const releaseNotes = readFileSync(
path.join(repoRoot, 'docs', 'releases', 'RELEASE_NOTES_v6.4.0-rc.1.md'),
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { API_SCOPE_OPTIONS } from '@/constants/apiScopes';
import { getAPITokenScopePresets } from '@/components/Settings/apiTokenManagerModel';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
const configuration = readFileSync(path.join(repoRoot, 'docs/CONFIGURATION.md'), 'utf8');
const tokens = configuration.split('## 🔑 API Tokens')[1]?.split('\n## TrueNAS')[0] ?? '';
const normalized = tokens.replace(/\s+/g, ' ');
function tableRows(section: string) {
return section
.split('\n')
.filter((line) => line.startsWith('|') && !/^\|\s*-/.test(line))
.slice(1)
.map((line) =>
line
.split('|')
.slice(1, -1)
.map((cell) => cell.trim()),
);
}
describe('shipped API Access guidance', () => {
it('names the existing token controls rather than the retired navigation', () => {
expect(normalized).toContain('Settings → API Access');
expect(normalized).not.toContain('Security → API Tokens');
for (const control of ['New token', 'Create token', 'Quick preset', 'Generate']) {
expect(normalized).toContain(control);
}
expect(normalized).toContain('cannot create or revoke tokens');
});
it('documents every UI scope without inventing capabilities', () => {
const scopeSection = tokens.split('### Token Scopes')[1]?.split('### Presets')[0] ?? '';
const scopes = tableRows(scopeSection).map(([scope]) => scope.match(/`([^`]+)`/)?.[1]);
expect(scopes.sort()).toEqual(['*', ...API_SCOPE_OPTIONS.map((scope) => scope.value)].sort());
});
it('matches names and exact least-privilege scope sets to the actual preset model', () => {
const presetSection = tokens.split('### Presets')[1]?.split('### Kiosk Mode')[0] ?? '';
const documented = tableRows(presetSection).map(([label, scopes]) => ({
label: label.replace(/\*\*/g, ''),
scopes: [...scopes.matchAll(/`([^`]+)`/g)].map((match) => match[1]),
}));
expect(documented).toEqual(
getAPITokenScopePresets().map(({ label, scopes }) => ({ label, scopes })),
);
expect(normalized).toContain('Reporting is not remote execution');
expect(normalized).toContain('not to fix a missing reading');
expect(normalized).toContain('separate collector and action-runner');
});
it('distinguishes backend-required Patrol scopes and wildcard authority from presets', () => {
expect(normalized).toContain('its scopes come from the current Patrol requirements');
expect(normalized).toContain('legacy `*` wildcard, not a least-privilege preset');
expect(normalized).toContain("Hiding controls does not reduce a token's permissions");
});
it('keeps credential rotation separate from identity deletion and exposure response', () => {
const rotation =
tokens.split('### Replace or revoke a token')[1]?.split('### Token Scopes')[0] ?? '';
const text = rotation.replace(/\s+/g, ' ');
expect(text).toContain('fresh authenticated result before revoking the old token');
expect(text).toContain('Last-used metadata alone does not account for every consumer');
expect(text).toContain('Do not reinstall an agent or delete its saved identity');
expect(text).toContain('revoke it promptly');
expect(text).toContain('even if that interrupts monitoring or a display');
expect(text).toContain('Do not leave a leaked token active');
expect(text).toContain('[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address)');
expect(text).toContain('[private header-file procedure](API.md#api-token-recommended)');
});
it('does not present a hidden dashboard or cleaned URL as a credential boundary', () => {
const kiosk = tokens.split('### Kiosk Mode')[1] ?? '';
const text = kiosk.replace(/\s+/g, ' ');
expect(text).toContain('Kiosk / Monitoring');
expect(text).toContain('only `monitoring:read`');
expect(text).toContain('Magic Kiosk Link');
expect(text).toContain('Copy Link');
expect(text).toContain('HTTPS');
expect(text).toContain('no administrator session');
expect(text).toContain('bearer credential');
expect(text).toContain('it does not authenticate a browser or change');
expect(text).toContain('initial request already carried the token');
expect(text).toContain('proxy/server logs');
expect(text).toContain('session storage');
expect(text).toContain('Do not rely on this session surviving');
expect(text).not.toContain('?token=YOUR_TOKEN_HERE');
expect(text).not.toContain('avoid cookie persistence issues');
});
it('ships the same scope guidance the UI links to', () => {
expect(
readFileSync(path.join(repoRoot, 'frontend-modern/public/docs/CONFIGURATION.md'), 'utf8'),
).toBe(configuration);
});
});
@@ -1464,6 +1464,141 @@ describe('fast merge path for metrics-only delta patches', () => {
new Set(raw.map((row) => row.id)),
);
it('clears omitted guest read-state fields on a new native outcome in full and fast deltas', () => {
const raw = createPveGuestRaw();
raw.proxmox = {
...raw.proxmox,
runtimeStatus: 'running',
guestAgentStatus: 'deferred',
diskStatusReason: 'prev-vm-locked',
guestAgentExpected: true,
lock: 'backup',
};
const previous = seedDisplayRows([raw]);
const incoming = structuredClone(raw);
incoming.proxmox = {
vmid: 100,
nodeName: 'pve-node-1',
runtimeStatus: 'running',
guestAgentStatus: 'available',
};
const changed = new Set([raw.id]);
const keys = new Map([[raw.id, ['proxmox']]]);
expect(getFastResourceMergePatchKeys(keys, raw.id, previous[0])).toEqual(['proxmox']);
const full = mergeCanonicalResourceSnapshot([incoming], previous);
const delta = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed);
const fast = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed, keys);
expect(fast[0]).toEqual(delta[0]);
expect(fast[0]).toEqual(full[0]);
for (const [merged] of [full, delta, fast]) {
expect(merged.proxmox?.guestAgentStatus).toBe('available');
expect(merged.proxmox?.diskStatusReason).toBeUndefined();
expect(merged.proxmox?.lock).toBeUndefined();
expect(merged.proxmox?.guestAgentExpected).toBeUndefined();
expect(merged.proxmox?.uptime).toBe(1000); // unrelated richer facet is retained
expect(buildFastResourceStorePatchOps(merged, ['proxmox'])).toContainEqual({
key: 'proxmox',
value: merged.proxmox,
mode: 'reconcile',
});
}
});
it('applies the same read-state clearing to the compatibility provider facet', () => {
const raw = createPveGuestRaw();
raw.proxmox = {
...raw.proxmox,
runtimeStatus: 'running',
guestAgentStatus: 'deferred',
diskStatusReason: 'prev-vm-locked',
guestAgentExpected: true,
lock: 'backup',
};
raw.platformData = { ...raw.platformData, proxmox: raw.proxmox };
const previous = seedDisplayRows([raw]);
const incoming = structuredClone(raw);
incoming.proxmox = {
vmid: 100,
nodeName: 'pve-node-1',
runtimeStatus: 'running',
guestAgentStatus: 'available',
};
incoming.platformData = { ...incoming.platformData, proxmox: incoming.proxmox };
const [merged] = mergeCanonicalResourceSnapshot([incoming], previous);
expect(merged.platformData?.proxmox).toEqual(merged.proxmox);
expect(merged.proxmox?.diskStatusReason).toBeUndefined();
expect(merged.proxmox?.lock).toBeUndefined();
});
it('preserves read-state evidence on partial facet omission, including the fast path', () => {
const raw = createPveGuestRaw();
raw.proxmox = {
...raw.proxmox,
runtimeStatus: 'running',
guestAgentStatus: 'deferred',
diskStatusReason: 'prev-agent-timeout',
guestAgentExpected: true,
lock: 'backup',
};
const previous = seedDisplayRows([raw]);
for (const proxmox of [
undefined,
{ vmid: 100, uptime: 1001 },
{ guestAgentStatus: 'available' },
{ vmid: 0, runtimeStatus: 'running', guestAgentStatus: 'available' },
{ vmid: 1.5, runtimeStatus: 'running', guestAgentStatus: 'available' },
{ vmid: Infinity, runtimeStatus: 'running', guestAgentStatus: 'available' },
{ vmid: 100, runtimeStatus: ' ', guestAgentStatus: 'available' },
{ vmid: 100, runtimeStatus: 'running', guestAgentStatus: ' ' },
]) {
const incoming = { ...raw, proxmox } as Resource;
const full = mergeCanonicalResourceSnapshot([incoming], previous);
const fast = mergeCanonicalResourceDeltaSnapshot(
[incoming],
previous,
new Set([raw.id]),
new Map([[raw.id, ['proxmox']]]),
);
for (const [merged] of [full, fast]) {
expect(merged.proxmox).toMatchObject({
diskStatusReason: 'prev-agent-timeout',
guestAgentExpected: true,
lock: 'backup',
});
}
}
});
it('keeps explicit lock and deferral evidence on a new outcome, including false expected', () => {
const raw = createPveGuestRaw();
raw.proxmox = {
...raw.proxmox,
runtimeStatus: 'running',
guestAgentStatus: 'available',
};
const previous = seedDisplayRows([raw]);
const incoming = structuredClone(raw);
incoming.proxmox = {
...raw.proxmox,
guestAgentStatus: 'deferred',
diskStatusReason: 'agent-busy',
guestAgentExpected: false,
lock: 'migrate',
};
const [merged] = mergeCanonicalResourceDeltaSnapshot(
[incoming],
previous,
new Set([raw.id]),
new Map([[raw.id, ['proxmox']]]),
);
expect(merged.proxmox).toMatchObject({
guestAgentStatus: 'deferred',
diskStatusReason: 'agent-busy',
guestAgentExpected: false,
lock: 'migrate',
});
});
it('produces the same merged row as the full path for a metrics-only patch', () => {
const raw = createPveGuestRaw();
const display = seedDisplayRows([raw]);
@@ -122,6 +122,31 @@ const mergeRecord = <T extends JsonRecord>(incoming?: T, existing?: T): T | unde
return { ...existing, ...incoming };
};
// Native VM facets carry a runtime state and an explicit guest-agent outcome.
// Their optional read reason, lock and false expected flag are omitempty on
// wire. A new outcome supersedes those fields, not unrelated richer metadata.
// Partial/legacy facets without that evidence must retain the prior facts.
const mergeProxmoxFacet = <T extends JsonRecord>(incoming?: T, existing?: T): T | undefined => {
const merged = mergeRecord(incoming, existing);
if (
!incoming ||
!merged ||
typeof incoming.vmid !== 'number' ||
!Number.isInteger(incoming.vmid) ||
incoming.vmid <= 0 ||
typeof incoming.runtimeStatus !== 'string' ||
!incoming.runtimeStatus.trim() ||
typeof incoming.guestAgentStatus !== 'string' ||
!incoming.guestAgentStatus.trim()
)
return merged;
const next = { ...merged };
for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock']) {
if (!Object.prototype.hasOwnProperty.call(incoming, key)) delete next[key];
}
return next;
};
const mergePlatformData = (
incomingValue: Resource['platformData'],
existingValue: Resource['platformData'],
@@ -151,7 +176,10 @@ const mergePlatformData = (
delete merged[key];
continue;
}
const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key]));
const nested =
key === 'proxmox'
? mergeProxmoxFacet(asRecord(incoming[key]), asRecord(existing[key]))
: mergeRecord(asRecord(incoming[key]), asRecord(existing[key]));
if (key === 'docker') {
if (hasDockerFacetEvidence(nested)) {
merged[key] = nested;
@@ -864,7 +892,9 @@ const mergeCanonicalSourceFacet = <T extends JsonRecord>(
...sourceCandidates: string[]
): T | undefined =>
shouldKeepSourceFacet(incomingSources, ...sourceCandidates)
? mergeRecord(incomingFacet, existingFacet)
? sourceCandidates.includes('proxmox-pve')
? mergeProxmoxFacet(incomingFacet, existingFacet)
: mergeRecord(incomingFacet, existingFacet)
: incomingFacet;
// A missing field alone can be a partial snapshot. Explicit unavailable raw
@@ -1130,7 +1160,7 @@ const applyFastResourceMergePatch = (
getCanonicalSourceList(existing, existing.platformData),
'proxmox-pve',
)
? { ...existingFacet, ...cloned }
? mergeProxmoxFacet(cloned, existingFacet)
: cloned;
continue;
}
@@ -26,7 +26,10 @@ func testGuestAgentBackupMonitoringLifecycle(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
if p == 1 || p == 2 {
if p == 4 {
w.Header().Set("Content-Length", "1000")
fmt.Fprint(w, `{"data":{}}`)
} else if p == 1 || p == 2 {
fmt.Fprint(w, `{"data":{"lock":"backup"}}`)
} else {
fmt.Fprint(w, `{"data":{}}`)
@@ -98,6 +101,22 @@ func testGuestAgentBackupMonitoringLifecycle(t *testing.T) {
memKey := guestMemoryCacheKey("fixture", "node", 105)
originalMetadata := m.guestMetadataCache[metadataKey]
originalMemory := m.vmAgentMemCache[memKey]
// A syntactically unlocked prefix is not a completed config observation.
// Warm guest caches must stay labelled last-known rather than becoming new
// disk/memory History samples when the lock response is incomplete.
phase.Store(4)
unverified, _ := build()
if unverified.ID != initial.ID || unverified.GuestAgentStatus != "deferred" || unverified.DiskStatusReason != "prev-lock-unverified" || unverified.Disk.Used != initial.Disk.Used || unverified.Memory.Used != initial.Memory.Used {
t.Fatalf("incomplete lock response lost truthful continuity: %#v", unverified)
}
if calls.Load() != before || !reflect.DeepEqual(m.guestMetadataCache[metadataKey], originalMetadata) || !reflect.DeepEqual(m.vmAgentMemCache[memKey], originalMemory) {
t.Fatal("incomplete lock response sent a guest command or renewed caches")
}
for metric, want := range map[string]int{"cpu": 2, "memory": 1, "memoryused": 1, "disk": 1} {
if got := len(m.metricsHistory.GetGuestMetrics(identity, metric, time.Hour)); got != want {
t.Errorf("incomplete lock %s history points = %d, want %d", metric, got, want)
}
}
phase.Store(1)
res.CPU = 0.2
locked, source := build()
@@ -113,7 +132,7 @@ func testGuestAgentBackupMonitoringLifecycle(t *testing.T) {
if !reflect.DeepEqual(m.guestMetadataCache[metadataKey], originalMetadata) || !reflect.DeepEqual(m.vmAgentMemCache[memKey], originalMemory) {
t.Fatal("lock renewed/replaced cached observations")
}
for metric, want := range map[string]int{"cpu": 2, "memory": 1, "memoryused": 1, "disk": 1} {
for metric, want := range map[string]int{"cpu": 3, "memory": 1, "memoryused": 1, "disk": 1} {
if got := len(m.metricsHistory.GetGuestMetrics(identity, metric, time.Hour)); got != want {
t.Errorf("%s history points = %d, want %d", metric, got, want)
}
@@ -7,6 +7,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -53,6 +54,44 @@ func TestMetricWindowPointsUsesInMemoryMetricAlias(t *testing.T) {
}
}
func TestUsedMemoryHistoryCleanupThroughMonitorAndReaders(t *testing.T) {
previous := mock.IsMockEnabled()
mustSetMockEnabled(t, false)
defer mustSetMockEnabled(t, previous)
for _, resourceType := range []string{"vm", "container"} {
t.Run(resourceType, func(t *testing.T) {
now := time.Now()
history := NewMetricsHistory(32, 4*time.Hour)
monitor := &Monitor{metricsHistory: history}
const id = "instance:node:105"
monitor.recordGuestMetric(resourceType, id, 20, 60, 4096, -1, -1, -1, -1, -1, now.Add(-2*time.Hour))
monitor.recordGuestMetric(resourceType, id, 25, -1, -1, -1, -1, -1, -1, -1, now.Add(-time.Minute))
monitor.cleanupMetricsHistory()
if got := monitor.GetGuestMetrics(id, 4*time.Hour)["memoryused"]; len(got) != 1 || got[0].Value != 4096 {
t.Fatalf("in-window last-known bytes were lost or refreshed: %+v", got)
}
history.retentionTime = time.Hour
monitor.cleanupMetricsHistory()
if got := monitor.GetGuestMetrics(id, 4*time.Hour)["memoryused"]; len(got) != 0 {
t.Fatalf("ongoing CPU retained expired byte samples: %+v", got)
}
monitor.recordGuestMetric(resourceType, id, 30, 70, 8192, -1, -1, -1, -1, -1, now)
monitor.cleanupMetricsHistory()
chart := monitor.GetGuestMetricsForChart(id, resourceType, id, 4*time.Hour)["memoryused"]
if len(chart) != 1 || chart[0].Value != 8192 || !chart[0].Timestamp.Equal(now) {
t.Fatalf("chart did not retain the fresh byte observation: %+v", chart)
}
points, err := monitor.metricWindowPoints(alerts.MetricWindowRequest{
ResourceID: id, ResourceType: resourceType, Metric: "memoryused",
Start: now.Add(-4 * time.Hour), End: now,
})
if err != nil || len(points) != 1 || points[0].Value != 8192 || !points[0].Timestamp.Equal(now) {
t.Fatalf("metric window diverged from fresh chart bytes: points=%+v err=%v", points, err)
}
})
}
}
func TestMetricWindowMergePrefersFreshInMemoryDuplicate(t *testing.T) {
now := time.Now().UTC()
stored := []MetricPoint{
+4 -2
View File
@@ -771,6 +771,7 @@ func (mh *MetricsHistory) Cleanup() {
for key, metrics := range mh.guestMetrics {
metrics.CPU = mh.cleanupMetrics(metrics.CPU, cutoffTime)
metrics.Memory = mh.cleanupMetrics(metrics.Memory, cutoffTime)
metrics.MemoryUsed = mh.cleanupMetrics(metrics.MemoryUsed, cutoffTime)
metrics.Disk = mh.cleanupMetrics(metrics.Disk, cutoffTime)
metrics.GPU = mh.cleanupMetrics(metrics.GPU, cutoffTime)
metrics.GPUMemory = mh.cleanupMetrics(metrics.GPUMemory, cutoffTime)
@@ -782,7 +783,7 @@ func (mh *MetricsHistory) Cleanup() {
metrics.Temperature = mh.cleanupMetrics(metrics.Temperature, cutoffTime)
// If all slices are empty, remove the map entry entirely to free memory
if len(metrics.CPU) == 0 && len(metrics.Memory) == 0 && len(metrics.Disk) == 0 &&
if len(metrics.CPU) == 0 && len(metrics.Memory) == 0 && len(metrics.MemoryUsed) == 0 && len(metrics.Disk) == 0 &&
len(metrics.GPU) == 0 && len(metrics.GPUMemory) == 0 && len(metrics.GPUTemperature) == 0 &&
len(metrics.DiskRead) == 0 && len(metrics.DiskWrite) == 0 &&
len(metrics.NetworkIn) == 0 && len(metrics.NetworkOut) == 0 &&
@@ -796,12 +797,13 @@ func (mh *MetricsHistory) Cleanup() {
for key, metrics := range mh.nodeMetrics {
metrics.CPU = mh.cleanupMetrics(metrics.CPU, cutoffTime)
metrics.Memory = mh.cleanupMetrics(metrics.Memory, cutoffTime)
metrics.MemoryUsed = mh.cleanupMetrics(metrics.MemoryUsed, cutoffTime)
metrics.Disk = mh.cleanupMetrics(metrics.Disk, cutoffTime)
metrics.NetworkIn = mh.cleanupMetrics(metrics.NetworkIn, cutoffTime)
metrics.NetworkOut = mh.cleanupMetrics(metrics.NetworkOut, cutoffTime)
metrics.Temperature = mh.cleanupMetrics(metrics.Temperature, cutoffTime)
if len(metrics.CPU) == 0 && len(metrics.Memory) == 0 && len(metrics.Disk) == 0 &&
if len(metrics.CPU) == 0 && len(metrics.Memory) == 0 && len(metrics.MemoryUsed) == 0 && len(metrics.Disk) == 0 &&
len(metrics.NetworkIn) == 0 && len(metrics.NetworkOut) == 0 &&
len(metrics.Temperature) == 0 {
delete(mh.nodeMetrics, key)
@@ -13,13 +13,14 @@ func TestMetricsHistoryConcurrentAccess(t *testing.T) {
const iterations = 1000
var wg sync.WaitGroup
wg.Add(4)
wg.Add(5)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
mh.AddGuestMetric("guest-1", "cpu", rand.Float64()*100, time.Now())
mh.AddGuestMetric("guest-1", "memory", rand.Float64()*100, time.Now())
mh.AddGuestMetric("guest-1", "memoryused", 4096, time.Now())
mh.AddGuestMetric("guest-2", "disk", rand.Float64()*100, time.Now())
time.Sleep(time.Microsecond)
}
@@ -30,6 +31,7 @@ func TestMetricsHistoryConcurrentAccess(t *testing.T) {
for i := 0; i < iterations; i++ {
mh.AddNodeMetric("node-1", "cpu", rand.Float64()*100, time.Now())
mh.AddNodeMetric("node-1", "memory", rand.Float64()*100, time.Now())
mh.AddNodeMetric("node-1", "memoryused", 8192, time.Now())
mh.AddStorageMetric("storage-1", "usage", rand.Float64()*100, time.Now())
time.Sleep(time.Microsecond)
}
@@ -40,6 +42,7 @@ func TestMetricsHistoryConcurrentAccess(t *testing.T) {
for i := 0; i < iterations; i++ {
mh.GetGuestMetrics("guest-1", "cpu", time.Minute)
mh.GetGuestMetrics("guest-1", "memory", time.Minute)
mh.GetGuestMetrics("guest-1", "memoryused", time.Minute)
mh.GetGuestMetrics("guest-2", "disk", time.Minute)
time.Sleep(time.Microsecond)
}
@@ -50,13 +53,26 @@ func TestMetricsHistoryConcurrentAccess(t *testing.T) {
for i := 0; i < iterations; i++ {
mh.GetNodeMetrics("node-1", "cpu", time.Minute)
mh.GetNodeMetrics("node-1", "memory", time.Minute)
mh.GetNodeMetrics("node-1", "memoryused", time.Minute)
// Storage metrics are exposed through Monitor, but simulate reads via guest metrics map
mh.GetGuestMetrics("storage-1", "usage", time.Minute)
time.Sleep(time.Microsecond)
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
mh.Cleanup()
time.Sleep(time.Microsecond)
}
}()
wg.Wait()
if len(mh.GetGuestMetrics("guest-1", "memoryused", time.Minute)) == 0 ||
len(mh.GetNodeMetrics("node-1", "memoryused", time.Minute)) == 0 {
t.Fatal("concurrent cleanup lost live used-memory samples")
}
}
// rand is automatically seeded in Go 1.20+
@@ -126,3 +126,36 @@ func TestCleanupReleasesSeedSizedCapacity(t *testing.T) {
t.Fatalf("cleanup still aliases the seed-sized backing array (len=%d cap-was=%d)", len(series), before)
}
}
func TestCleanupUsedMemoryReleasesSeedSizedCapacity(t *testing.T) {
for _, scope := range []string{"guest", "node"} {
t.Run(scope, func(t *testing.T) {
mh := NewMetricsHistory(3500, 4*time.Hour)
add := mh.AddGuestMetric
entries := mh.guestMetrics
if scope == "node" {
add, entries = mh.AddNodeMetric, mh.nodeMetrics
}
now := time.Now()
for i := 0; i < 2000; i++ {
add("resource", "memoryused", float64(i), now.Add(-2*time.Hour).Add(time.Duration(i)*time.Second))
}
add("resource", "memoryused", 4096, now.Add(-time.Minute))
add("resource", "cpu", 25, now)
original := entries["resource"].MemoryUsed
mh.retentionTime = time.Hour
mh.Cleanup()
series := entries["resource"].MemoryUsed
if len(series) != 1 || series[0].Value != 4096 || !series[0].Timestamp.Equal(now.Add(-time.Minute)) {
t.Fatalf("used-memory retention kept %d points, want the unchanged live point", len(series))
}
if cap(series) > 4*len(series)+64 {
t.Fatalf("used-memory cleanup pinned an oversized array: len=%d cap=%d", len(series), cap(series))
}
if &series[0] == &original[0] || &series[0] == &original[len(original)-1] {
t.Fatal("used-memory cleanup still aliases the seed-sized array")
}
t.Logf("used-memory samples %d -> %d; backing capacity %d -> %d", len(original), len(series), cap(original), cap(series))
})
}
}
@@ -60,6 +60,83 @@ func TestNewMetricsHistory(t *testing.T) {
}
}
// Use the public writers and readers for every supported series. In particular,
// MemoryUsed must participate in both retention and resource-entry ownership;
// testing cleanupMetrics alone cannot detect an omitted field in Cleanup.
func TestMetricsHistoryCleanupEverySeries(t *testing.T) {
for _, scope := range []string{"guest", "node", "storage", "disk"} {
metricTypes := map[string][]string{
"guest": {"cpu", "memory", "memoryused", "disk", "gpu", "gpu_memory", "gpu_temperature", "diskread", "diskwrite", "netin", "netout", "temperature"},
"node": {"cpu", "memory", "memoryused", "disk", "netin", "netout", "temperature"},
"storage": {"usage", "used", "total", "avail"},
"disk": {"smart_temp", "disk", "diskread", "diskwrite"},
}[scope]
for _, metric := range metricTypes {
for _, scenario := range []string{"live-only", "expired-with-live-neighbour", "mixed"} {
t.Run(scope+"/"+metric+"/"+scenario, func(t *testing.T) {
mh := NewMetricsHistory(32, 4*time.Hour)
now := time.Now()
const id = "resource"
add := mh.AddGuestMetric
read := mh.GetGuestMetrics
exists := func() bool { return mh.guestMetrics[id] != nil }
switch scope {
case "node":
add, read = mh.AddNodeMetric, mh.GetNodeMetrics
exists = func() bool { return mh.nodeMetrics[id] != nil }
case "storage":
add = mh.AddStorageMetric
read = func(id, metric string, duration time.Duration) []MetricPoint {
return mh.GetAllStorageMetrics(id, duration)[metric]
}
exists = func() bool { return mh.storageMetrics[id] != nil }
case "disk":
add, read = mh.AddDiskMetric, mh.GetDiskMetrics
exists = func() bool { return mh.diskMetrics[id] != nil }
}
if scenario != "live-only" {
add(id, metric, 11, now.Add(-2*time.Hour))
}
if scenario != "expired-with-live-neighbour" {
add(id, metric, 22, now.Add(-time.Minute))
} else {
// Another fresh series must not pin this expired one.
keeper := metricTypes[0]
if keeper == metric {
keeper = metricTypes[1]
}
add(id, keeper, 33, now)
}
// Model elapsed retention without waiting or weakening the
// writer's immediate expiry check for backfilled samples.
mh.retentionTime = time.Hour
mh.Cleanup()
if !exists() {
t.Fatal("cleanup removed a resource with a live series")
}
points := read(id, metric, 4*time.Hour)
if scenario == "expired-with-live-neighbour" {
if len(points) != 0 {
t.Fatalf("expired series remains: %+v", points)
}
} else if len(points) != 1 || points[0].Value != 22 || !points[0].Timestamp.Equal(now.Add(-time.Minute)) {
t.Fatalf("cleanup changed live values/timestamps or retained old points: %+v", points)
}
mh.Cleanup()
if len(read(id, metric, 4*time.Hour)) != len(points) {
t.Fatal("repeated cleanup changed the retained series")
}
mh.retentionTime = 0
mh.Cleanup()
if exists() {
t.Fatal("fully expired resource remains in History")
}
})
}
}
}
}
func TestAppendMetric(t *testing.T) {
now := time.Now()
+8 -1
View File
@@ -1596,11 +1596,18 @@ func (c *Client) GetVMConfig(ctx context.Context, node string, vmid int) (map[st
}
defer resp.Body.Close()
// Guest-agent admission uses this response as authoritative lock evidence.
// Decoding only the first JSON value can accept an unlocked prefix before a
// truncated, stalled, oversized or malformed response has been received.
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, fmt.Errorf("read VM configuration: %w", err)
}
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
@@ -0,0 +1,164 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestGuestAgentConfigResponseMustBeComplete(t *testing.T) {
for _, test := range []struct {
name string
write func(http.ResponseWriter)
}{
{"truncated", func(w http.ResponseWriter) {
w.Header().Set("Content-Length", "1000")
fmt.Fprint(w, `{"data":{}}`)
}},
{"trailing-json", func(w http.ResponseWriter) {
fmt.Fprint(w, `{"data":{}}{"data":{"lock":"backup"}}`)
}},
{"trailing-garbage", func(w http.ResponseWriter) {
fmt.Fprint(w, `{"data":{}}incomplete-response`)
}},
{"chunked-over-limit", func(w http.ResponseWriter) {
fmt.Fprint(w, `{"data":{}}`)
w.(http.Flusher).Flush() // No declared length: enforce the read boundary.
fmt.Fprint(w, strings.Repeat(" ", int(maxResponseBodyBytes)))
}},
} {
t.Run(test.name, func(t *testing.T) {
for name, read := range backupAgentReads() {
for _, postCommand := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/post=%t", name, postCommand), func(t *testing.T) {
var configs, commands atomic.Int32
var healthy atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/config") {
if configs.Add(1) == 1 && postCommand || healthy.Load() {
fmt.Fprint(w, `{"data":{}}`)
} else {
test.write(w)
}
return
}
commands.Add(1)
backupAgentPayload(w, r)
}))
defer server.Close()
c := backupTestClient(t, server.URL)
if err := read(context.Background(), c, 105); GuestAgentDeferredReason(err) != "lock-unverified" {
t.Errorf("incomplete config was accepted: %v", err)
}
wantCommands := int32(0)
if postCommand {
wantCommands = 1
}
if got := commands.Load(); got != wantCommands {
t.Errorf("guest commands = %d, want %d", got, wantCommands)
}
// This config failure says nothing about the completed guest
// command. A later ordinary read must recheck the lock and resume.
healthy.Store(true)
if err := read(context.Background(), c, 105); err != nil {
t.Fatalf("complete unlocked config did not resume: %v", err)
}
if got := commands.Load(); got != wantCommands+1 {
t.Errorf("resumed guest commands = %d, want %d", got, wantCommands+1)
}
})
}
}
})
}
}
func TestGuestAgentConfigPrefixDoesNotAdmitCommand(t *testing.T) {
var commands atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/config") {
fmt.Fprint(w, `{"data":{}}`)
w.(http.Flusher).Flush()
<-r.Context().Done() // The body never completes, despite a valid prefix.
return
}
commands.Add(1)
backupAgentPayload(w, r)
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if _, err := backupTestClient(t, server.URL).GetVMAgentInfo(ctx, "node", 105); GuestAgentDeferredReason(err) != "lock-unverified" {
t.Errorf("stalled config admitted a command: %v", err)
}
if got := commands.Load(); got != 0 {
t.Errorf("commands before config completion = %d, want zero", got)
}
}
func TestGuestAgentAdmissionIncludesPostConfigBody(t *testing.T) {
var configs, commands atomic.Int32
entered := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/config") {
n := configs.Add(1)
fmt.Fprint(w, `{"data":{}}`)
if n == 2 {
w.(http.Flusher).Flush()
close(entered)
<-r.Context().Done()
}
return
}
commands.Add(1)
backupAgentPayload(w, r)
}))
defer server.Close()
c1, c2 := backupTestClient(t, server.URL), backupTestClient(t, server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
value, err := c1.GetVMFSInfo(ctx, "node", 105)
if value != nil {
t.Error("filesystem payload escaped the incomplete post-command lock check")
}
done <- err
}()
select {
case <-entered:
case err := <-done:
t.Fatalf("post-command config check never started: %v", err)
}
if _, err := c2.GetVMNetworkInterfaces(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "agent-busy" {
t.Errorf("second client bypassed incomplete config admission: %v", err)
}
if err := <-done; GuestAgentDeferredReason(err) != "lock-unverified" {
t.Errorf("post-command config prefix accepted: %v", err)
}
if got := commands.Load(); got != 1 {
t.Errorf("commands during incomplete post-check = %d, want one", got)
}
if _, err := c2.GetVMFSInfo(context.Background(), "node", 105); err != nil {
t.Fatalf("admission not released after failed lock check: %v", err)
}
}
func TestVMConfigCompleteWhitespaceAtResponseLimit(t *testing.T) {
const config = `{"data":{"name":"fixture","agent":1}}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, config)
w.(http.Flusher).Flush()
fmt.Fprint(w, strings.Repeat(" ", int(maxResponseBodyBytes)-len(config)))
}))
defer server.Close()
got, err := backupTestClient(t, server.URL).GetVMConfig(context.Background(), "node", 105)
if err != nil || got["name"] != "fixture" || got["agent"] != float64(1) {
t.Fatalf("complete bounded config rejected or changed: %#v %v", got, err)
}
}
+38
View File
@@ -1,7 +1,13 @@
package proxmox
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
@@ -138,3 +144,35 @@ func TestBackupLocksRetainCumulativeCounterPresence(t *testing.T) {
})
}
}
func TestUnverifiedVMConfigPreservesStatusCounterObservations(t *testing.T) {
var commands atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
w.Header().Set("Content-Length", "1000")
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/status/current"):
fmt.Fprint(w, `{"data":{"status":"running","cpu":0.25,"diskread":0,"diskwrite":null,"netin":42}}`)
default:
commands.Add(1)
backupAgentPayload(w, r)
}
}))
defer server.Close()
c := backupTestClient(t, server.URL)
if _, err := c.GetVMFSInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "lock-unverified" {
t.Errorf("incomplete config did not defer the guest command: %v", err)
}
status, err := c.GetVMStatus(context.Background(), "node", 105)
if err != nil {
t.Fatal(err)
}
p := status.IOCounters.Effective()
if status.CPU != 0.25 || status.NetIn != 42 || status.DiskRead != 0 || status.ObservedAt.IsZero() || !p.DiskRead || !p.NetworkIn || p.DiskWrite || p.NetworkOut {
t.Fatalf("unverified guest lock suppressed/changed current PVE counters: %+v", status)
}
if got := commands.Load(); got != 0 {
t.Errorf("guest commands with incomplete config = %d, want zero", got)
}
}
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Bind a hosted browser journey to two exact published, signed release assets.
Reads local metadata and archives; performs no network, extraction or execution.
The workflow separately verifies SSH signatures before --archives is supplied.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import tarfile
MAX_ARCHIVE = 512 * 1024 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_BINARY = 1024 * 1024 * 1024
MAX_EXPANDED = 2 * MAX_BINARY
def release_identity(release, *, tag, commit, prerelease):
pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
if prerelease:
pattern += r'-rc\.[1-9][0-9]*'
if (not re.fullmatch(pattern, tag) or not re.fullmatch(r'[0-9a-f]{40}', commit)
or release.get('tag_name') != tag or release.get('target_commitish') != commit
or release.get('draft') is not False or release.get('prerelease') is not prerelease
or release.get('immutable') is not True or not release.get('published_at')
or type(release.get('id')) is not int or release['id'] <= 0):
raise ValueError('Exact published immutable release/tag identity required')
asset_name = f'pulse-{tag}-linux-amd64.tar.gz'
assets = []
for name in (asset_name, asset_name + '.sshsig'):
matches = [a for a in release.get('assets', []) if a.get('name') == name]
if len(matches) != 1:
raise ValueError('Missing or ambiguous release asset')
asset = matches[0]
if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'
or type(asset.get('id')) is not int or asset['id'] <= 0
or type(asset.get('size')) is not int or not 0 < asset['size'] <= (MAX_SIGNATURE if name.endswith('.sshsig') else MAX_ARCHIVE)
or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))):
raise ValueError('Unsafe or unbound release asset')
assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']})
return {'tag': tag, 'release_id': release['id'], 'commit': commit, 'assets': assets}
def file_sha256(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def archive_identity(path):
# Inspect only one bounded regular binary; do not extract the package.
with tarfile.open(path, 'r:gz') as archive:
matches = []
total_size = 0
for i, member in enumerate(archive):
total_size += member.size
if total_size > MAX_EXPANDED:
raise ValueError('Oversized expanded release archive')
if i >= 4096:
raise ValueError('Oversized release archive inventory')
if member.name in ('./bin/pulse', 'bin/pulse'):
matches.append(member)
if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= MAX_BINARY:
raise ValueError('Missing or ambiguous regular server binary')
stream = archive.extractfile(matches[0])
return hashlib.file_digest(stream, 'sha256').hexdigest()
def packet(baseline, candidate, from_commit, to_commit, tag, archives=None):
stable_tag = baseline.get('tag_name', '')
releases = [release_identity(baseline, tag=stable_tag, commit=from_commit, prerelease=False),
release_identity(candidate, tag=tag, commit=to_commit, prerelease=True)]
if tuple(map(int, stable_tag[1:].split('.'))) >= tuple(map(int, tag[1:].split('-')[0].split('.'))):
raise ValueError('The candidate must be newer than its published stable baseline')
if archives is not None:
for release in releases:
for asset in release['assets']:
p = archives / release['tag'] / asset['name']
if p.is_symlink() or not p.is_file() or p.stat().st_size != asset['size']:
raise ValueError('Release asset size/type differs from live identity')
if file_sha256(p) != asset['digest'].split(':')[1]:
raise ValueError('Release asset digest differs from live identity')
release['binary_sha256'] = archive_identity(archives / release['tag'] / release['assets'][0]['name'])
return {'schema_version': 1, 'repository': 'rcourtman/Pulse', 'releases': releases}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline', required=True, type=Path)
parser.add_argument('--candidate', required=True, type=Path)
parser.add_argument('--from-commit', required=True)
parser.add_argument('--to-commit', required=True)
parser.add_argument('--tag', required=True)
parser.add_argument('--archives', type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
result = packet(json.loads(args.baseline.read_text()), json.loads(args.candidate.read_text()),
args.from_commit, args.to_commit, args.tag, args.archives)
args.output.write_text(json.dumps(result, indent=2) + '\n')
if __name__ == '__main__':
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Validate only the joined CI node's identity before exposing its loopback UI.
Reads local `tailscale status --json`; makes no network request and never emits
peer inventory, raw status or credentials. OAuth/ACL scope is configured by the
Tailnet owner, not inferred from a DNS name or a tag observation.
"""
import json
import re
import sys
MAX_STATUS_BYTES = 1024 * 1024
ERROR = "Release browser requires a running Self node with only tag:ci-deploy and a business-tailnet DNS name"
def self_origin(status):
if not isinstance(status, dict) or status.get("BackendState") != "Running":
raise ValueError(ERROR)
node = status.get("Self")
if (not isinstance(node, dict) or node.get("Online") is not True
or node.get("Tags") != ["tag:ci-deploy"]):
raise ValueError(ERROR)
name = node.get("DNSName")
if not isinstance(name, str):
raise ValueError(ERROR)
name = name.removesuffix(".")
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.tawny-powan\.ts\.net", name):
raise ValueError(ERROR)
return "https://" + name
def main():
try:
raw = sys.stdin.buffer.read(MAX_STATUS_BYTES + 1)
if len(raw) > MAX_STATUS_BYTES:
raise ValueError(ERROR)
origin = self_origin(json.loads(raw))
except (ValueError, UnicodeError):
print(ERROR, file=sys.stderr)
return 2
print(origin)
return 0
if __name__ == "__main__":
sys.exit(main())
+167 -14
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
#
# Release lifecycle rehearsal: install, upgrade and roll back Pulse between two
# PUBLISHED releases on a real systemd host (a privileged systemd container).
# PUBLISHED releases on a real systemd host. Default: shadow systemd container.
# --hosted-browser: fresh public GitHub VM, authenticated Tailscale Serve UI
# update/SSE, documented CLI recovery and browser/session readback.
#
# 1. install FROM with that release's signature-verified install.sh --version
# 2. assert FROM identity, health and unit state
@@ -49,10 +51,12 @@ SEED_ADMIN_USER="rehearsal-admin"
FROM_TAG=""
TO_TAG=""
HOSTED_BROWSER=false
CHECK_SNAPSHOT=""
COMPARE_DATADIR=()
while [[ $# -gt 0 ]]; do
case "$1" in
--hosted-browser) HOSTED_BROWSER=true; shift ;;
--from) FROM_TAG="${2:-}"; shift 2 ;;
--to) TO_TAG="${2:-}"; shift 2 ;;
# Offline self-tests used by the contract tests: apply the fixed
@@ -78,6 +82,30 @@ if [[ -z "$CHECK_SNAPSHOT" && ${#COMPARE_DATADIR[@]} -eq 0 ]]; then
fi
fi
# The browser route is a real first-level public CI host, not a container
# overriding Docker detection. It must never install onto a maintainer host.
if [[ "$HOSTED_BROWSER" == true ]]; then
if [[ "$EUID" != 0 || "${GITHUB_ACTIONS:-}" != true \
|| "${RUNNER_ENVIRONMENT:-}" != github-hosted \
|| "${GITHUB_REPOSITORY:-}" != rcourtman/Pulse \
|| "${GITHUB_REF:-}" != refs/heads/main \
|| -e /opt/pulse || -e /etc/pulse \
|| -e /etc/systemd/system/pulse.service || -e /rehearsal ]]; then
echo "::error::Browser lifecycle requires a fresh disposable public hosted runner."
exit 2
fi
if [[ ! -f "${PULSE_REHEARSAL_PACKET_MANIFEST:-}" ]]; then
echo "::error::A verified immutable release asset manifest is required."
exit 2
fi
# The workflow derives this from the new runner's own Tailscale identity.
# No caller endpoint, old stopped target or TLS-verification bypass exists.
if [[ ! "${PULSE_REHEARSAL_BROWSER_ORIGIN:-}" =~ ^https://[a-z0-9-]+\.tawny-powan\.ts\.net$ ]]; then
echo "::error::Browser lifecycle requires the ephemeral runner's HTTPS Serve origin."
exit 2
fi
fi
REPO="${PULSE_REHEARSAL_REPO:-rcourtman/Pulse}"
if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "::error::invalid PULSE_REHEARSAL_REPO '${REPO}'" >&2
@@ -126,11 +154,22 @@ cexec() {
for pair in "$@"; do
env_args+=(-e "$pair")
done
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
if [[ "$HOSTED_BROWSER" == true ]]; then
# Never pass workflow, GitHub or tailnet-registration credentials to
# the installed application or an acceptance command.
env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
"${@}" bash -c "$program"
else
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
fi
}
print_diagnostics() {
[[ "$CONTAINER_STARTED" == "true" ]] || return 0
if [[ "$HOSTED_BROWSER" == true ]]; then
echo "::error::Hosted lifecycle failed; inspect bounded browser/identity receipts. No raw credentials or journal retained."
return
fi
echo "::group::Diagnostics: pulse journal (tail)"
cexec 'journalctl -u pulse --no-pager | tail -n 150' || true
echo "::endgroup::"
@@ -155,8 +194,28 @@ cleanup() {
print_diagnostics
fi
if [[ "$CONTAINER_STARTED" == "true" ]]; then
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
if [[ "$HOSTED_BROWSER" == true ]]; then
local cleanup_status=0
systemctl stop pulse.service || cleanup_status=1
if [[ "$(systemctl show pulse-update.timer --property=LoadState --value)" != not-found ]]; then
systemctl stop pulse-update.timer || cleanup_status=1
fi
if systemctl is-active --quiet pulse.service; then cleanup_status=1; fi
if systemctl is-active --quiet pulse-update.timer; then cleanup_status=1; fi
printf '{"cleanup_exit":%s,"host_disposal":"github-hosted-job"}\n' "$cleanup_status" > "${WORK_DIR}/state/browser-cleanup.json"
[[ "$cleanup_status" == 0 ]] || status=1
else
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
# Ephemeral application credentials are never retained as evidence.
if [[ -f "${WORK_DIR}/state/browser-auth.json" ]]; then
: > "${WORK_DIR}/state/browser-auth.json"
fi
fi
trap - EXIT
exit "$status"
}
trap cleanup EXIT
@@ -165,7 +224,11 @@ write_summary() {
table=$(
echo "## Release lifecycle rehearsal"
echo
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
if [[ "$HOSTED_BROWSER" == true ]]; then
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a fresh public hosted VM with HTTPS Tailscale Serve. Browser acceptance evidence, not a stable promotion decision."
else
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
fi
echo
echo "| Phase | Expected | Reported version | Health | Settings | Data dir | Units | Result |"
echo "| --- | --- | --- | --- | --- | --- | --- | --- |"
@@ -182,6 +245,12 @@ write_summary() {
done
fi
)
if [[ "$HOSTED_BROWSER" == true ]]; then
jq -cn --argjson overall_status "$OVERALL_STATUS" \
--argjson phases "$(printf '%s\n' "${PHASE_ROWS[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \
'{schema_version:1, overall_status:$overall_status, phases:$phases}' \
> "${WORK_DIR}/state/lifecycle-result.json"
fi
echo
echo "$table"
if [[ -n "$SUMMARY_FILE" ]]; then
@@ -216,8 +285,10 @@ fetch_and_verify_installer() {
local dest="${WORK_DIR}/assets"
local base="https://github.com/${REPO}/releases/download/${tag}"
local asset
local -a retry_args=(--retry 5 --retry-delay 5 --retry-all-errors)
if [[ "$HOSTED_BROWSER" == true ]]; then retry_args=(--retry 0 --max-time 120); fi
for asset in install.sh install.sh.sshsig; do
if ! curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
if ! curl -fsSL "${retry_args[@]}" \
-o "${dest}/${asset}" "${base}/${asset}"; then
echo "::error::could not download ${base}/${asset}"
return 1
@@ -246,6 +317,13 @@ fetch_and_verify_installer() {
echo "::error::${tag} install.sh is not the Pulse server installer with --version support"
return 1
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
jq -cn --arg tag "$tag" \
--arg installer_sha256 "$(sha256sum "${dest}/install.sh" | cut -d' ' -f1)" \
--arg signature_sha256 "$(sha256sum "${dest}/install.sh.sshsig" | cut -d' ' -f1)" \
'{tag:$tag,installer_sha256:$installer_sha256,signature_sha256:$signature_sha256,signature_verified:true}' \
> "${WORK_DIR}/state/installer.json"
fi
log "${tag} install.sh signature verifies against the README-pinned key"
}
@@ -254,6 +332,14 @@ fetch_and_verify_installer() {
# ---------------------------------------------------------------------------
start_container() {
if [[ "$HOSTED_BROWSER" == true ]]; then
CONTAINER_STARTED=true
BASELINE_FAILED_UNITS=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort')
# Keep the same telemetry opt-out, without changing deployment identity,
# enabling Docker updates, mocking feeds or weakening authentication.
cexec 'mkdir -p /etc/systemd/system/pulse.service.d && printf "[Service]\nEnvironment=PULSE_TELEMETRY=false\n" > /etc/systemd/system/pulse.service.d/50-rehearsal-no-telemetry.conf'
return
fi
local -a run_args=(run -d --name "$CONTAINER" --privileged
-v "${WORK_DIR}/assets:/rehearsal:ro")
if [[ "$ENGINE" == "docker" ]]; then
@@ -349,6 +435,15 @@ assert_runtime() {
note_failure "/opt/pulse/bin/pulse --version reported '${binary_version:-none}', expected ${expected_tag}"
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
local observed_digest expected_digest
observed_digest=$(cexec 'sha256sum /opt/pulse/bin/pulse | cut -d" " -f1')
expected_digest=$(jq -er --arg tag "$expected_tag" '.releases[] | select(.tag == $tag) | .binary_sha256' "$PULSE_REHEARSAL_PACKET_MANIFEST")
printf '{"tag":"%s","binary_sha256":"%s","expected_binary_sha256":"%s"}\n' \
"$expected_tag" "$observed_digest" "$expected_digest" > "${WORK_DIR}/state/installed-${expected_tag}.json"
[[ "$observed_digest" == "$expected_digest" ]] || note_failure "Installed binary differs from the verified published archive"
fi
local enabled failed_now new_failed
enabled=$(cexec 'systemctl is-enabled pulse' 2>/dev/null || true)
failed_now=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort' 2>/dev/null || true)
@@ -605,12 +700,27 @@ check_settings() {
# Phases
# ---------------------------------------------------------------------------
install_baseline() {
if [[ "$HOSTED_BROWSER" == true ]]; then
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
> "${WORK_DIR}/state/private-install.log" 2>&1
else
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"
fi
}
phase_install() {
reset_phase_cells
log "Phase 1: install ${FROM_TAG} with its published install.sh --version"
# No TTY: install.sh's prompts take their defaults, as for curl | bash.
if ! cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"; then
if [[ "$HOSTED_BROWSER" == true ]]; then
# /rehearsal is the fixed mount spelling used by the existing harness.
# It is absent on a fresh hosted runner; never overwrite an existing path.
[[ ! -e /rehearsal ]] || return 1
ln -s "${WORK_DIR}/assets" /rehearsal
fi
if ! install_baseline; then
note_failure "install.sh --version ${FROM_TAG} exited non-zero"
fi
assert_runtime "$FROM_TAG"
@@ -635,7 +745,7 @@ phase_seed() {
if ! setup_response=$(cexec 'curl -fsS -X POST -H "Content-Type: application/json" -H "X-Setup-Token: $SETUP_TOKEN" --data "$BODY" "$API/api/security/quick-setup"' \
"API=${API}" "SETUP_TOKEN=${setup_token}" "BODY=${setup_body}") \
|| [[ "$(jq -r '.success // false' <<<"$setup_response" 2>/dev/null)" != "true" ]]; then
note_failure "first-run security setup failed: ${setup_response:-no response}"
note_failure "first-run security setup did not return success"
fi
local webhook_body node_body
@@ -695,28 +805,71 @@ run_updater() {
fi
# /bin/update downloads the latest published install.sh, verifies it with
# its embedded signer key and runs it with the given arguments.
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
if [[ "$HOSTED_BROWSER" == true ]]; then
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
> "${WORK_DIR}/state/private-updater.log" 2>&1; then
note_failure "/bin/update --version ${target} exited non-zero"
fi
elif ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
2>&1 | tee "${WORK_DIR}/state/${label}-${target}.log"; then
note_failure "/bin/update --version ${target} exited non-zero"
fi
}
run_browser_journey() {
local mode="$1" expected="$2"
if [[ "$mode" == recovery ]]; then
# An unreadable/missing/incomplete receipt cannot authorise another
# browser process either. Only an explicit non-refused terminal result
# permits readback after the independent documented CLI recovery.
local receipt="${WORK_DIR}/state/browser-upgrade.json" reason
if ! jq -e 'type == "object" and .access_refused == false
and (.status == "passed" or .status == "failed")' "$receipt" >/dev/null 2>&1; then
reason=upgrade-receipt-unavailable
if jq -e '.access_refused == true' "$receipt" >/dev/null 2>&1; then
reason=stopped-access-no-reauthentication
fi
printf '{"status":"not-executed","reason":"%s"}\n' "$reason" \
> "${WORK_DIR}/state/browser-recovery.json"
return 1
fi
fi
(umask 077; jq -cn --arg username "$SEED_ADMIN_USER" --arg password "$ADMIN_PASSWORD" \
'{username:$username,password:$password}' > "${WORK_DIR}/state/browser-auth.json")
env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="${WORK_DIR}" \
PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pulse-browser}" \
"${PULSE_REHEARSAL_NODE:?}" "${ROOT_DIR}/tests/integration/scripts/release-browser-journey.cjs" \
--origin "$PULSE_REHEARSAL_BROWSER_ORIGIN" --mode "$mode" \
--from "$FROM_TAG" --to "$TO_TAG" --expected "$expected" \
--auth-file "${WORK_DIR}/state/browser-auth.json" --output-dir "${WORK_DIR}/state"
}
phase_upgrade() {
reset_phase_cells
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
run_updater "$TO_TAG" upgrade || true
if [[ "$HOSTED_BROWSER" == true ]]; then
log "Phase 3: upgrade to ${TO_TAG} through the authenticated Tailscale Serve browser"
run_browser_journey upgrade "$TO_TAG" || note_failure "Tailscale browser upgrade failed; no apply replay"
else
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
run_updater "$TO_TAG" upgrade || true
fi
assert_runtime "$TO_TAG"
check_auto_update_intent upgrade || true
check_settings upgrade || true
check_datadir upgrade || true
record_phase "3. upgrade (/bin/update)" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
record_phase "3. upgrade ($([[ "$HOSTED_BROWSER" == true ]] && printf browser || printf /bin/update))" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
}
phase_rollback() {
reset_phase_cells
log "Phase 4: roll back with /bin/update --version ${FROM_TAG}"
# The documented recovery command is exercised even after an adverse
# browser update. It is not a second attempt to apply the candidate.
run_updater "$FROM_TAG" rollback || true
assert_runtime "$FROM_TAG"
if [[ "$HOSTED_BROWSER" == true ]]; then
run_browser_journey recovery "$FROM_TAG" || note_failure "Tailscale browser recovery/readback failed"
fi
check_auto_update_intent rollback || true
check_settings rollback || true
check_datadir rollback || true
@@ -724,13 +877,13 @@ phase_rollback() {
}
main() {
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO} with ${ENGINE}"
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO}; hosted browser=${HOSTED_BROWSER}"
if ! fetch_and_verify_installer "$FROM_TAG"; then
CURRENT_FAILURE="published install.sh for ${FROM_TAG} failed verification"
record_phase "0. verify installer" "$FROM_TAG" "-" "-" "-" "-" "-" || abort_run
fi
if ! start_container; then
CURRENT_FAILURE="systemd container did not start"
CURRENT_FAILURE="disposable systemd host did not start"
record_phase "0. systemd host" "-" "-" "-" "-" "-" "-" || abort_run
fi
+10 -2
View File
@@ -67,9 +67,17 @@ field_seen=false
agent_value=''
lock_value=''
while IFS= read -r line; do
if [[ $line =~ ^[A-Za-z][A-Za-z0-9_-]*: ]]; then
field_seen=true
if [[ $line =~ ^[[:space:]]*$ || $line =~ ^[[:space:]]*# ]]; then
continue
fi
# qm's fields are unindented lowercase keys. Do not let one valid field
# turn malformed/truncated lock or agent lines into an absent setting.
# Unknown well-formed fields remain valid; never echo an unparsed line.
if [[ ! $line =~ ^[a-z][a-z0-9_-]*: ]]; then
printf '%s\n' 'VM current configuration contains an unrecognised line; preflight incomplete.' >&2
exit 1
fi
field_seen=true
case "$line" in
agent:*)
if [[ $agent_seen == true ]]; then
@@ -0,0 +1,171 @@
"""Exact release identity and fail-closed hosted browser control tests, offline."""
import copy
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('browser_packet', ROOT / 'scripts/release_browser_packet.py')
packet = importlib.util.module_from_spec(spec)
spec.loader.exec_module(packet)
def release(tag, commit):
return {'id': 123, 'tag_name': tag, 'target_commitish': commit, 'draft': False,
'prerelease': '-rc.' in tag, 'immutable': True, 'published_at': '2026-10-02T19:21:28Z',
'assets': [{'id': i + 1, 'name': name, 'size': 5, 'digest': 'sha256:' + 'b' * 64,
'browser_download_url': f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'}
for i, name in enumerate((f'pulse-{tag}-linux-amd64.tar.gz', f'pulse-{tag}-linux-amd64.tar.gz.sshsig'))]}
class BrowserPacketTest(unittest.TestCase):
def setUp(self):
self.baseline = release('v6.4.5', 'a' * 40)
self.candidate = release('v6.4.6-rc.1', 'c' * 40)
def bind(self, archives=None):
return packet.packet(self.baseline, self.candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1', archives)
def test_binds_two_immutable_exact_commits_not_control_head(self):
result = self.bind()
self.assertEqual(['a' * 40, 'c' * 40], [r['commit'] for r in result['releases']])
self.assertEqual('rcourtman/Pulse', result['repository'])
self.assertNotIn('assets', result) # Only bounded constituent identities are retained.
def test_rejects_unpublished_mutable_wrong_maturity_or_ref(self):
for field, value in [('draft', True), ('immutable', False), ('prerelease', False),
('target_commitish', 'main'), ('tag_name', 'v6.4.6-rc.2'),
('published_at', None), ('id', True)]:
with self.subTest(field=field), self.assertRaises(ValueError):
candidate = self.candidate | {field: value}
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
with self.assertRaises(ValueError):
packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9')
def test_current_published_archives_fit_bounded_admission(self):
# Actual GitHub metadata readback: stable276780330 and RC277779060
# bytes both exceed the original256MiB limit. No asset bytes executed.
self.baseline['assets'][0]['size'] = 276780330
self.candidate['assets'][0]['size'] = 277779060
self.assertEqual(2, len(self.bind()['releases']))
self.candidate['assets'][1]['size'] = packet.MAX_SIGNATURE + 1
with self.assertRaises(ValueError):
self.bind()
def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self):
for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0),
('size', 513 * 1024 * 1024), ('digest', None), ('id', True)]:
with self.subTest(field=field), self.assertRaises(ValueError):
candidate = copy.deepcopy(self.candidate)
candidate['assets'][0][field] = value
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
self.candidate['assets'].append(self.candidate['assets'][0])
with self.assertRaises(ValueError):
self.bind()
def make_assets(self, root):
for r in (self.baseline, self.candidate):
folder = root / r['tag_name']; folder.mkdir()
for asset in r['assets']:
path = folder / asset['name']
if asset['name'].endswith('.sshsig'):
path.write_bytes(b'fixture signature - cryptographic verification belongs to SSH workflow')
else:
data = ('fixture-' + r['tag_name']).encode()
with tarfile.open(path, 'w:gz') as archive:
member = tarfile.TarInfo('./bin/pulse'); member.size = len(data)
archive.addfile(member, io.BytesIO(data))
asset['size'] = path.stat().st_size
asset['digest'] = 'sha256:' + hashlib.sha256(path.read_bytes()).hexdigest()
def test_asset_digest_and_binary_digest_both_checked_without_extraction(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); self.make_assets(root)
result = self.bind(root)
self.assertEqual(hashlib.sha256(b'fixture-v6.4.6-rc.1').hexdigest(), result['releases'][1]['binary_sha256'])
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
target.write_bytes(b'X' * target.stat().st_size)
with self.assertRaisesRegex(ValueError, 'digest'):
self.bind(root)
def test_symlink_and_duplicate_binary_are_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); self.make_assets(root)
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
real = target.with_suffix('.real'); target.rename(real); target.symlink_to(real)
with self.assertRaisesRegex(ValueError, 'size/type'):
self.bind(root)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
for names in (['./bin/pulse', 'bin/pulse'], ['./bin/pulse']):
with self.subTest(names=names):
with tarfile.open(path, 'w:gz') as archive:
for name in names:
member = tarfile.TarInfo(name); member.type = tarfile.SYMTYPE; member.linkname = '/etc/passwd'
archive.addfile(member)
with self.assertRaises(ValueError):
packet.archive_identity(path)
def test_native_mode_refuses_worker_before_install_or_network(self):
result = subprocess.run(['bash', ROOT / 'scripts/release_lifecycle_rehearsal.sh', '--hosted-browser',
'--from', 'v6.4.5', '--to', 'v6.4.6-rc.1'],
env={'PATH': '/usr/bin:/bin'}, capture_output=True, text=True)
self.assertEqual(2, result.returncode)
self.assertIn('fresh disposable public hosted runner', result.stdout)
def test_cleanup_failure_sets_process_exit_and_scrubs_only_ephemeral_auth(self):
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
cleanup = source.split('cleanup() {', 1)[1].split('\ntrap cleanup EXIT', 1)[0]
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); (root / 'state').mkdir(); (root / 'state/browser-auth.json').write_text('throwaway')
# Execute the real cleanup function with fixture systemctl: not a system/installation proof.
script = 'set -u\nOVERALL_STATUS=0; CONTAINER_STARTED=true; HOSTED_BROWSER=true\n'
script += f'WORK_DIR={json.dumps(tmp)}\nprint_diagnostics() {{ :; }}\nsystemctl() {{ return 1; }}\n'
script += 'cleanup() {' + cleanup + '\ntrap cleanup EXIT\nexit 0\n'
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual('', (root / 'state/browser-auth.json').read_text())
self.assertEqual(1, json.loads((root / 'state/browser-cleanup.json').read_text())['cleanup_exit'])
def test_cexec_native_environment_drops_provider_and_registration_secrets(self):
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0]
script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n'
result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin',
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret',
'TS_CI_DEPLOY_OAUTH_CLIENT_ID': 'synthetic-narrow-id',
'TS_CI_DEPLOY_OAUTH_SECRET': 'synthetic-narrow-secret'},
capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotIn('synthetic-secret', result.stdout)
self.assertNotIn('synthetic-narrow', result.stdout)
self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout)
self.assertIn('EXTRA=fixture', result.stdout)
def test_workflow_has_no_arbitrary_target_build_publish_or_credential_in_acceptance(self):
source = (ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
self.assertNotIn('pull_request:', source)
self.assertNotIn('contents: write', source)
self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text())
self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source)
self.assertIn('runs-on: ubuntu-24.04', source)
self.assertIn('tailscale status --json | python3 scripts/release_browser_tailnet.py', source)
self.assertIn('ssh-keygen -Y verify', source)
acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0]
self.assertIn('sudo env -i', acceptance)
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'TS_CI_DEPLOY_OAUTH_CLIENT_ID',
'TS_CI_DEPLOY_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
self.assertNotIn(name, acceptance)
artifact = source.split('- name: Retain bounded', 1)[1]
for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'):
self.assertNotIn(private, artifact)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,164 @@
"""Offline controls for the actual browser workflow's narrow registration route."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[2]
HELPER = ROOT / "scripts/release_browser_tailnet.py"
WORKFLOW = ROOT / ".github/workflows/qualify-browser-update-release.yml"
SPEC = importlib.util.spec_from_file_location("release_browser_tailnet", HELPER)
tailnet = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(tailnet)
def status():
return {"BackendState": "Running", "Self": {"Online": True,
"Tags": ["tag:ci-deploy"], "DNSName": "ci-browser.tawny-powan.ts.net."},
"Peer": {"private": {"DNSName": "private-peer", "private_data": "not-for-receipts"}}}
def workflow_step(name):
step = WORKFLOW.read_text().split(" - name: " + name + "\n", 1)[1].split(" - name:", 1)[0]
return textwrap.dedent(step.split(" run: |\n", 1)[1])
def assert_registration_binding(case, source):
# Closed, literal action inputs: no demo fallback or caller-selected tag/key.
action = source.split("uses: tailscale/github-action@", 1)[1].split(" - name:", 1)[0]
expected = {
"oauth-client-id": "${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}",
"oauth-secret": "${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}",
"tags": "tag:ci-deploy",
"version": "'1.94.2'",
}
actual = dict(line.strip().split(": ", 1) for line in action.split(" with:\n", 1)[1].splitlines() if line.strip())
case.assertEqual(expected, actual)
for broad in ("secrets.TS_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_SECRET", "tags: tag:infra"):
case.assertNotIn(broad, source)
class SelfIdentityTest(unittest.TestCase):
def test_reads_only_the_running_narrow_self_identity(self):
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(status()))
observed = status()
observed["Peer"] = "untrusted and deliberately not a peer inventory"
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(observed))
observed["Self"]["DNSName"] = "a.tawny-powan.ts.net"
self.assertEqual("https://a.tawny-powan.ts.net", tailnet.self_origin(observed))
def test_rejects_broad_extra_missing_and_malformed_tags(self):
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], [], None,
"tag:ci-deploy", ["tag:ci-deploy", "tag:ci-deploy"], ["tag:production"]):
with self.subTest(tags=tags), self.assertRaisesRegex(ValueError, "only tag:ci-deploy"):
observed = status()
observed["Self"]["Tags"] = tags
tailnet.self_origin(observed)
def test_rejects_non_self_wrong_tailnet_and_unsafe_dns(self):
for name in (None, 1, "private-peer", "ci.other.ts.net", "https://ci.tawny-powan.ts.net",
"ci.tawny-powan.ts.net\nsecret", "ci.tawny-powan.ts.net..",
"-ci.tawny-powan.ts.net", "ci-.tawny-powan.ts.net",
"a" * 64 + ".tawny-powan.ts.net"):
with self.subTest(name=name), self.assertRaises(ValueError):
observed = status()
observed["Self"]["DNSName"] = name
tailnet.self_origin(observed)
for observed in (None, [], {}, status() | {"Self": []}, status() | {"BackendState": "NeedsLogin"}):
with self.subTest(observed=observed), self.assertRaises(ValueError):
tailnet.self_origin(observed)
for online in (False, None, 1, "true"):
with self.subTest(online=online), self.assertRaises(ValueError):
observed = status()
observed["Self"]["Online"] = online
tailnet.self_origin(observed)
def test_cli_errors_never_return_raw_status_or_peers(self):
invalid = status()
invalid["Self"]["Tags"] = ["tag:infra"]
for raw in (json.dumps(invalid).encode(), b"private diagnostic is not JSON", b"\xff",
b" " * (tailnet.MAX_STATUS_BYTES + 1)):
with self.subTest(bytes=len(raw)):
result = subprocess.run(["python3", HELPER], input=raw, capture_output=True)
self.assertEqual(2, result.returncode)
self.assertEqual(b"", result.stdout)
self.assertEqual((tailnet.ERROR + "\n").encode(), result.stderr)
class WorkflowBindingTest(unittest.TestCase):
def test_only_dedicated_tag_scoped_oauth_binding_is_declared(self):
source = WORKFLOW.read_text()
assert_registration_binding(self, source)
self.assertIn("id: tailnet\n", source)
self.assertIn("if: always() && steps.tailnet.outcome == 'success'", source)
self.assertNotIn("steps.serve.outcome == 'success'", source)
def test_rejects_demo_credentials_and_broad_or_caller_selected_registration(self):
source = WORKFLOW.read_text()
for before, after in (
("secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_CLIENT_ID"),
("secrets.TS_CI_DEPLOY_OAUTH_SECRET", "secrets.TS_OAUTH_SECRET"),
("tags: tag:ci-deploy", "tags: tag:infra"),
("tags: tag:ci-deploy", "tags: tag:ci-deploy,tag:infra"),
("tags: tag:ci-deploy", "tags: ${{ inputs.tags }}"),
("tags: tag:ci-deploy", "authkey: ${{ secrets.TS_OAUTH_SECRET }}"),
):
with self.subTest(after=after), self.assertRaises(AssertionError):
assert_registration_binding(self, source.replace(before, after))
def test_missing_narrow_binding_stops_even_when_demo_credentials_exist(self):
script = workflow_step("Require the dedicated narrow CI registration binding")
for client_id, secret in (("", ""), ("present", ""), ("", "present"), ("present", "present")):
env = {"PATH": os.environ["PATH"], "CI_CLIENT_ID": client_id, "CI_CLIENT_SECRET": secret,
"TS_OAUTH_CLIENT_ID": "broad-id-not-to-be-used", "TS_OAUTH_SECRET": "broad-secret-not-to-be-used"}
result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True)
self.assertEqual(0 if client_id and secret else 2, result.returncode)
self.assertEqual("", result.stdout)
self.assertNotIn("broad-id-not-to-be-used", result.stderr)
self.assertNotIn("broad-secret-not-to-be-used", result.stderr)
def test_actual_serve_step_stops_before_serve_and_output_on_wrong_identity(self):
script = workflow_step("Serve only this disposable install")
fixtures = [status()]
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], []):
observed = status()
observed["Self"]["Tags"] = tags
fixtures.append(observed)
observed = status()
observed["Self"]["DNSName"] = "private-peer.other.ts.net"
fixtures.append(observed)
fixtures.append({})
for i, observed in enumerate(fixtures):
with self.subTest(fixture=i), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "status.json").write_text(json.dumps(observed))
fake = work / "tailscale"
fake.write_text('#!/bin/bash\nset -eu\n'
'if [[ "$*" == "status --json" ]]; then cat "$FIXTURE/status.json"; '
'else printf "%s\\n" "$*" >> "$FIXTURE/serve-calls"; fi\n')
fake.chmod(0o700)
env = {"PATH": str(work) + ":" + os.environ["PATH"], "FIXTURE": tmp,
"GITHUB_OUTPUT": str(work / "output")}
result = subprocess.run(["bash", "-c", script], cwd=ROOT, env=env, capture_output=True, text=True)
self.assertEqual(0 if i == 0 else 2, result.returncode, result.stderr)
self.assertEqual("", result.stdout)
self.assertNotIn("private-peer", result.stderr)
self.assertNotIn("not-for-receipts", result.stderr)
if i == 0:
self.assertEqual("serve --bg --https=443 http://127.0.0.1:7655\n", (work / "serve-calls").read_text())
header, value, delimiter = (work / "output").read_text().splitlines()
self.assertEqual("origin<<" + delimiter, header)
self.assertTrue(delimiter.startswith("pulse_output_"))
self.assertEqual("https://ci-browser.tawny-powan.ts.net", value)
else:
self.assertFalse((work / "serve-calls").exists())
self.assertFalse((work / "output").exists())
if __name__ == "__main__":
unittest.main()
@@ -177,6 +177,58 @@ class HarnessContractTest(unittest.TestCase):
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
class HostedWorkflowCommandFilesTest(unittest.TestCase):
def test_bound_pair_uses_the_reviewed_collision_safe_output_writer(self) -> None:
source = (REPO_ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
for field in ('from', 'from_commit', 'to_commit'):
self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source)
self.assertNotIn('echo "to_commit=', source)
node_setup = source.split('uses: actions/setup-node@', 1)[1].split(' - name:', 1)[0]
self.assertNotRegex(node_setup, r'(?m)^\s*cache:')
self.assertIn('package-manager-cache: false', source)
class BrowserRecoveryAccessStopTest(unittest.TestCase):
def test_refused_upgrade_does_not_launch_another_login(self) -> None:
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
for status in (401, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
(work / "state/browser-upgrade.json").write_text(json.dumps({"status": "failed", "access_refused": True, "login_status": status}))
script = "set -euo pipefail\nWORK_DIR=" + json.dumps(tmp) + "\n"
script += function + "\nrun_browser_journey recovery v6.4.5\n"
# NODE/auth/origin variables are intentionally absent. Reaching
# process/auth preparation would fail this exact guard test.
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual({"status": "not-executed", "reason": "stopped-access-no-reauthentication"},
json.loads((work / "state/browser-recovery.json").read_text()))
self.assertFalse((work / "state/browser-auth.json").exists())
self.assertEqual("", result.stderr)
def test_missing_or_malformed_upgrade_receipt_stops_before_auth_preparation(self) -> None:
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
for receipt in (None, "not JSON", "[]", '{"status":"failed"}',
'{"status":"incomplete","access_refused":false}',
'{"status":"failed","access_refused":"false"}'):
with self.subTest(receipt=receipt), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
if receipt is not None:
(work / "state/browser-upgrade.json").write_text(receipt)
result = subprocess.run(["bash", "-c", "set -euo pipefail\nWORK_DIR="
+ json.dumps(tmp) + "\n" + function + "\nrun_browser_journey recovery v6.4.5\n"],
capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual({"status": "not-executed", "reason": "upgrade-receipt-unavailable"},
json.loads((work / "state/browser-recovery.json").read_text()))
self.assertFalse((work / "state/browser-auth.json").exists())
self.assertEqual("", result.stderr)
class AutoUpdateIntentTest(unittest.TestCase):
"""Execute the real observer/comparator, with guest systemctl observations."""
@@ -425,5 +477,73 @@ class WorkflowContractTest(unittest.TestCase):
self.assertNotIn("release-lifecycle-rehearsal", path.read_text(encoding="utf-8"))
def verify_browser_recovery_controls(controls_path: Path) -> None:
"""Feed exact Chromium receipts into the real guard in a jq-equipped proof VM.
The guard's permitted process is a local sentinel, not native recovery.
Source proof binds both this source and the browser input's exact hash.
"""
controls = json.loads(controls_path.read_text(encoding="utf-8"))
assert controls["schema_version"] == 1
assert controls["scope"] == "offline adversarial observer controls only"
assert controls["client_version"] == "1.56.1"
fixed = {(kind, "upgrade") for kind in ("success", "anonymous-probes", "restart-gap",
"wrong-preview", "silent-stream", "stuck-modal")}
fixed.add(("success", "recovery"))
fixed.update((f"cleanup-{status}", "upgrade") for status in (401, 403))
for endpoint in ("navigation", "authenticated-navigation", "login", "initial-version",
"authenticated-api", "check", "apply", "stream", "version", "health", "read"):
fixed.update((f"{endpoint}-{status}", "upgrade") for status in (401, 403))
results = controls["results"]
assert len(results) == len(fixed) == 31
assert {(row["fixture"], row["mode"]) for row in results} == fixed
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
observed = []
for row in results:
kind, mode, receipt = row["fixture"], row["mode"], row["receipt"]
refused = re.search(r"-(401|403)$", kind) is not None
assert receipt["mode"] == mode and receipt["context_closed"] is True
assert receipt["access_refused"] is refused
assert row["requests_after_denial"] == 0
if refused:
assert receipt["status"] == "failed" and row["denials"] == 1
assert receipt["access_refusals"][0]["status"] == int(kind[-3:])
if mode != "upgrade":
continue
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
(work / "state/browser-upgrade.json").write_text(json.dumps(receipt), encoding="utf-8")
started = work / "process-started"
sentinel = work / "process-sentinel.sh"
sentinel.write_text("#!/bin/sh\nprintf started > " + json.dumps(str(started)) + "\n", encoding="utf-8")
sentinel.chmod(0o700)
result = subprocess.run(["bash", "-c", "set -euo pipefail\n" + function
+ "\nrun_browser_journey recovery v6.4.5\n"], capture_output=True, text=True, timeout=5,
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "WORK_DIR": tmp, "ROOT_DIR": str(REPO_ROOT),
"SEED_ADMIN_USER": "synthetic", "ADMIN_PASSWORD": "synthetic", "PULSE_REHEARSAL_NODE": str(sentinel),
"PULSE_REHEARSAL_BROWSER_ORIGIN": "https://ephemeral.tawny-powan.ts.net",
"FROM_TAG": "v6.4.5", "TO_TAG": "v6.4.6-rc.1"})
assert result.returncode == (1 if refused else 0) and result.stderr == ""
assert started.exists() is (not refused)
auth_prepared = (work / "state/browser-auth.json").exists()
assert auth_prepared is (not refused)
if refused:
assert json.loads((work / "state/browser-recovery.json").read_text()) == {
"status": "not-executed", "reason": "stopped-access-no-reauthentication"}
observed.append({"fixture": kind, "guard_exit": result.returncode,
"browser_process_started": started.exists(), "auth_prepared": auth_prepared})
print(json.dumps({"scope": "actual shell guard with bound offline Chromium receipts, not native recovery",
"browser_controls": len(results), "executed_recovery_guards": len(observed), "results": observed}))
if __name__ == "__main__":
unittest.main()
if len(sys.argv) == 3 and sys.argv[1] == "--browser-controls":
try:
verify_browser_recovery_controls(Path(sys.argv[2]))
except Exception:
print("Bound browser receipts failed recovery controls; no installed proof.", file=sys.stderr)
sys.exit(1)
else:
unittest.main()
+4 -1
View File
@@ -91,6 +91,9 @@ class TrueNASDocsTest(unittest.TestCase):
def test_polling_guidance_preserves_evidence_before_a_manual_test(self):
text = DOC.read_text().split("### Stale TrueNAS data", 1)[1].split("### Disabling", 1)[0]
# Markdown wrapping and sentence capitalization do not change the
# safety guidance. Still require every complete warning and field.
prose = " ".join(text.split()).casefold()
for required in (
"Record the existing state before testing or restarting",
"signed-in Pulse admin browser session",
@@ -107,7 +110,7 @@ class TrueNASDocsTest(unittest.TestCase):
"Do not post the full connection response",
):
with self.subTest(required=required):
self.assertIn(required, text)
self.assertIn(" ".join(required.split()).casefold(), prose)
self.assertNotIn("```", text)
self.assertNotIn("Check TrueNAS connectivity and API key validity", text)
# Every suggested field belongs to the existing read-only projection,
+38
View File
@@ -185,6 +185,44 @@ class VMDiskDiagnosticsTest(unittest.TestCase):
self.assertNotIn("preflight completed", result.stdout)
self.assert_passive(calls, operations=READS[:1] if "status_output" in settings else READS)
def test_malformed_lines_cannot_hide_agent_or_lock_configuration(self):
lines = (
" lock: backup", "\tlock: backup", "lock=backup", "lock : backup",
"Lock: backup", "LOCK: backup", "lock", "agent 1", "Agent: 1",
"warning " + PRIVATE,
)
for line in lines:
for suffix in ("", "\n"):
with self.subTest(line=line, suffix=suffix):
config = f"agent: 1\nname: {PRIVATE}\n{line}{suffix}"
result, calls = self.exercise(settings={"config_output": config})
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("unrecognised line; preflight incomplete", result.stderr)
self.assertNotIn("Guest agent configured:", result.stdout)
self.assertNotIn("VM lock:", result.stdout)
self.assertNotIn("preflight completed", result.stdout)
self.assert_passive(calls)
def test_blank_comments_and_unknown_well_formed_fields_remain_passive(self):
for agent in (None, "0", "1"):
with self.subTest(agent=agent):
config = (f"\n# {PRIVATE}\n \t\nfuture_read_option: {PRIVATE}\n"
f"\t# lock: backup {PRIVATE}\nname: {PRIVATE}\n")
if agent is not None:
config += f"agent: {agent}\n"
result, calls = self.exercise(settings={"config_output": config})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Guest agent configured: enabled" if agent == "1"
else "Guest agent configured: disabled", result.stdout)
self.assertIn("VM lock: not reported", result.stdout)
self.assert_passive(calls)
result, calls = self.exercise(settings={"config_output": f"\n# {PRIVATE}\n \t\n"})
self.assertEqual(result.returncode, 1)
self.assertIn("preflight incomplete", result.stderr)
self.assertNotIn("VM lock:", result.stdout)
self.assert_passive(calls)
def test_help_and_invalid_arguments_never_contact_proxmox(self):
for args in ([], ["0"], ["-1"], ["100;touch BAD"], ["100", "--probe"],
["9999999999"], ["--probe"], ["--help"]):
+6
View File
@@ -61,6 +61,12 @@ class WorkflowTrustTest(unittest.TestCase):
self.assertIn("^Test(InstallPS1|WindowsAgentLifecycle)", native)
self.assertIn("node-version: '24'", native)
def test_browser_release_uses_governed_node_line(self) -> None:
browser = (REPO_ROOT / ".github/workflows/qualify-browser-update-release.yml").read_text()
self.assertEqual(browser.count("actions/setup-node@"), 1)
self.assertIn("node-version: '24'", browser)
self.assertNotIn("node-version: '24.", browser)
def test_accepts_immutable_dependencies_and_explicit_checkout_credentials(self) -> None:
findings = self.audit(
f"""permissions:
@@ -0,0 +1,276 @@
/* Real, bounded browser observation of the disposable release install.
* No intercepted API responses, mocked versions, TLS bypass or apply replay.
* The CLI accepts only the new runner's HTTPS Tailscale Serve origin. Exported
* functions allow offline adversarial fixtures to test the observer itself.
*/
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require('playwright');
function validate(options) {
assert.match(options.origin, /^https:\/\/[a-z0-9-]+\.tawny-powan\.ts\.net$/);
assert.match(options.from, /^v\d+\.\d+\.\d+$/);
assert.match(options.to, /^v\d+\.\d+\.\d+-rc\.[1-9]\d*$/);
assert.ok(['upgrade', 'recovery'].includes(options.mode));
assert.equal(options.expected, options.mode === 'upgrade' ? options.to : options.from);
}
function judge(record) {
assert.equal(record.access_refused, false, 'denied access is never acceptance');
assert.equal(record.login_status, 200, 'authenticated UI login must succeed');
assert.equal(record.observed_version, record.expected.replace(/^v/, ''), 'wrong served version');
assert.equal(record.health, 'healthy', 'installed server is not healthy');
assert.equal(record.protected_read_status, 200, 'session did not survive/recover');
if (record.mode === 'upgrade') {
assert.equal(record.apply_requests, 1, 'exactly one UI apply is required');
assert.equal(record.apply_status, 200, 'apply was not accepted');
assert.equal(record.selected_version, record.to.replace(/^v/, ''), 'wrong release selected');
assert.ok(record.sse_opens > 0, 'no real EventSource connection opened');
assert.ok(record.sse_progress.some(p => p.status !== 'idle' && p.progress > 0),
'no live SSE progress received');
assert.equal(record.progress_dialog_visible, false, 'progress modal remained stuck after readiness');
}
}
async function runJourney(browser, options) {
const record = { schema_version: 1, mode: options.mode, from: options.from, to: options.to,
expected: options.expected, browser_version: browser.version(),
client_version: require('playwright/package.json').version, active_step: 'start', login_status: null, apply_requests: 0, apply_status: null,
sse_opens: 0, sse_progress: [], access_refused: false, access_refusals: [],
navigation_statuses: [], status: 'incomplete' };
assert.equal(record.client_version, '1.56.1', 'browser client must match the reviewed lock');
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(options.timeout || 30000);
const origin = new URL(options.origin).origin;
const authenticatedRequests = new WeakSet();
let stopAccess;
const stopped = new Promise(resolve => { stopAccess = resolve; });
let closing;
const closeContext = () => closing ||= context.close().then(
() => { record.context_closed = true; },
() => { record.context_closed = false; record.status = 'failed'; record.failure_type = 'ContextCleanupError'; },
);
const requireAccess = () => assert.equal(record.access_refused, false,
'access refused; stop without another login, read or apply');
const step = async operation => {
requireAccess();
const value = await Promise.race([operation(), stopped.then(requireAccess)]);
requireAccess();
return value;
};
const refuse = (endpoint, status) => {
if (![401, 403].includes(status)) return;
if (record.access_refusals.length < 16) {
record.access_refusals.push({ endpoint, status, step: record.active_step });
}
record.access_refused = true; // Monotonic, including automatic UI reloads.
stopAccess();
// Stop the page too: EventSource/UI code must not reconnect after denial.
// No replacement response, alternate context or reauthentication is used.
void closeContext();
};
// Retain only closed status/progress fields, not messages, headers, cookies,
// request bodies, full response JSON or private resource data.
await page.exposeFunction('__observeReleaseStream', item => {
if (item.open === true) record.sse_opens++;
if (['idle', 'downloading', 'verifying', 'extracting', 'installing', 'restarting',
'completed', 'error', 'failed', 'checking', 'backing_up'].includes(item.status)
&& Number.isFinite(item.progress) && item.progress >= 0 && item.progress <= 100
&& record.sse_progress.length < 1000) {
record.sse_progress.push({ status: item.status, progress: item.progress });
}
});
await page.addInitScript(() => {
const NativeEventSource = window.EventSource;
window.EventSource = class extends NativeEventSource {
constructor(url, config) {
super(url, config);
if (new URL(url, location.href).pathname === '/api/updates/stream') {
this.addEventListener('open', () => window.__observeReleaseStream({ open: true }));
this.addEventListener('message', event => {
try {
const data = JSON.parse(event.data);
void window.__observeReleaseStream({ status: data.status, progress: data.progress });
} catch (_) { /* malformed data is not progress evidence */ }
});
}
}
};
});
page.on('request', request => {
const url = new URL(request.url());
if (url.origin !== origin) return;
// Bind authentication at request start, not response arrival. An anonymous
// auth probe can legitimately return 401, even just after login succeeds.
if (record.login_status === 200) authenticatedRequests.add(request);
if (url.pathname === '/api/updates/apply' && request.method() === 'POST') {
record.apply_requests++;
}
});
page.on('response', response => {
const url = new URL(response.url());
if (url.origin !== origin) return;
const endpoint = url.pathname;
const request = response.request();
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
if (record.navigation_statuses.length < 16) record.navigation_statuses.push(response.status());
refuse('navigation', response.status());
}
if (endpoint === '/api/login' && request.method() === 'POST') {
record.login_status = response.status();
refuse('login', response.status());
}
if (endpoint === '/api/updates/apply' && request.method() === 'POST') {
record.apply_status = response.status();
refuse('apply', response.status());
} else if (authenticatedRequests.has(request) && endpoint.startsWith('/api/')) {
if (endpoint === '/api/config/nodes') record.protected_read_status = response.status();
// Closed endpoint classes only: never retain a path, query, URL, body,
// error message, header, cookie or resource identity from the install.
const classes = { '/api/version': 'version', '/api/health': 'health',
'/api/config/nodes': 'protected_read', '/api/updates/check': 'update_check',
'/api/updates/stream': 'update_stream' };
refuse(classes[endpoint] || 'authenticated_api', response.status());
}
});
const navigate = async () => {
const response = await step(() => page.goto(`${options.origin}/settings/system-updates`,
{ waitUntil: 'domcontentloaded' }));
assert.equal(response?.status(), 200, 'navigation did not serve the UI');
};
try {
record.active_step = 'navigate_login';
await navigate();
record.active_step = 'login';
await step(() => page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username));
await step(() => page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password));
const login = page.waitForResponse(r => new URL(r.url()).pathname === '/api/login'
&& r.request().method() === 'POST').catch(() => null);
await step(() => page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click());
assert.equal((await step(() => login))?.status(), 200, 'login rejected; do not retry or change identity');
await step(() => page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' }));
record.active_step = 'navigate_authenticated';
await navigate();
if (options.mode === 'upgrade') {
record.active_step = 'select_exact_release';
const initial = await step(() => page.evaluate(async () => {
const response = await fetch('/api/version');
return { status: response.status, version: response.ok ? await response.json() : null };
}));
assert.equal(initial.status, 200, 'initial version read rejected');
assert.equal(initial.version.version.replace(/^v/, ''), options.from.replace(/^v/, ''));
assert.equal(initial.version.isDocker, false, 'a Docker override is not native acceptance');
assert.equal(initial.version.isSourceBuild, false, 'must run the published binary');
await step(() => page.getByRole('button', { name: /^Preview\b/ }).click());
await step(() => page.getByRole('button', { name: 'Save Changes', exact: true }).click());
const check = page.waitForResponse(r => new URL(r.url()).pathname === '/api/updates/check'
&& new URL(r.url()).searchParams.get('channel') === 'rc').catch(() => null);
await step(() => page.getByRole('button', { name: /Check for Updates/i }).click());
const checked = await step(() => check);
assert.equal(checked?.status(), 200, 'update check rejected; no alternate route');
const selected = await step(() => checked.json());
record.selected_version = selected.latestVersion?.replace(/^v/, '');
assert.equal(record.selected_version, options.to.replace(/^v/, ''), 'refuse a different preview');
assert.equal(selected.downloadUrl,
`https://github.com/rcourtman/Pulse/releases/download/${options.to}/pulse-${options.to}-linux-amd64.tar.gz`);
await step(() => page.getByRole('button', { name: 'Install Update', exact: true }).first().click());
const confirmation = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: 'Start Update' }) });
record.active_step = 'confirm_apply_once';
await step(() => confirmation.getByRole('checkbox').check());
// No command is replayed after a timeout, rejected POST or lost response.
await step(() => confirmation.getByRole('button', { name: 'Start Update', exact: true }).click());
await step(() => page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i })
.waitFor({ state: 'visible' }));
}
record.active_step = 'version_health_session_readiness';
const deadline = Date.now() + (options.readinessTimeout || 600000);
let ready = false;
while (Date.now() < deadline) {
// Read-only observations during a known restart may temporarily have no
// listener. An HTTP401 is not such a transport gap and stops this journey.
const observed = await step(() => page.evaluate(async () => {
const result = {};
try {
const version = await fetch('/api/version');
result.versionStatus = version.status;
if ([401, 403].includes(version.status)) return result;
const health = await fetch('/api/health');
result.healthStatus = health.status;
if ([401, 403].includes(health.status)) return result;
const protectedRead = await fetch('/api/config/nodes');
result.protectedStatus = protectedRead.status;
if ([401, 403].includes(protectedRead.status)) return result;
try { result.version = await version.json(); result.health = await health.json(); }
catch (_) { result.body_unavailable = true; }
return result;
} catch (_) { return { ...result, transport_unavailable: true }; }
}).catch(() => ({ transport_unavailable: true }))); // automatic reload is a transport gap, not denial
if (observed.protectedStatus !== undefined) record.protected_read_status = observed.protectedStatus;
// Also check returned statuses in case page destruction preceded the
// Playwright response event. No health/read retry follows either denial.
for (const [endpoint, status] of [['version', observed.versionStatus],
['health', observed.healthStatus], ['protected_read', observed.protectedStatus]]) refuse(endpoint, status);
requireAccess();
if (observed.versionStatus === 200 && observed.healthStatus === 200
&& observed.protectedStatus === 200 && typeof observed.version?.version === 'string'
&& observed.version.version.replace(/^v/, '') === options.expected.replace(/^v/, '')
&& observed.health?.status === 'healthy') {
record.observed_version = observed.version.version.replace(/^v/, '');
record.health = observed.health.status;
record.protected_read_status = observed.protectedStatus;
ready = true;
break;
}
await step(() => new Promise(resolve => setTimeout(resolve, 1000)));
}
assert.ok(ready, 'readiness deadline expired');
// The UI gets its own bounded window to dismiss/reload after version readiness.
record.active_step = 'progress_modal_completion';
const progress = page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i });
await step(() => progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 }));
record.progress_dialog_visible = await step(() => progress.isVisible());
if (options.outputDir) await step(() => page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true }));
judge(record);
record.active_step = 'complete';
record.status = 'passed';
} catch (error) {
record.status = 'failed';
// Playwright errors can contain URLs, page text or values. Preserve a fixed
// type, not arbitrary error text or credentials, in the public receipt.
record.failure_type = error.name === 'TimeoutError' ? 'TimeoutError' : 'AssertionOrOperationError';
throw error;
} finally {
await closeContext();
if (record.access_refused) {
record.status = 'failed';
record.failure_type ||= 'AssertionOrOperationError';
}
if (options.outputDir) fs.writeFileSync(path.join(options.outputDir, `browser-${options.mode}.json`), JSON.stringify(record, null, 2) + '\n');
assert.equal(record.context_closed, true, 'browser context cleanup failed');
requireAccess(); // A response received during cleanup cannot become a pass.
}
return record;
}
async function main() {
const args = process.argv.slice(2);
const fields = { '--origin': 'origin', '--mode': 'mode', '--from': 'from', '--to': 'to',
'--expected': 'expected', '--auth-file': 'authFile', '--output-dir': 'outputDir' };
// Seven fixed options; no endpoint, credential, browser or command override.
assert.equal(args.length, 14);
const options = {};
for (let i = 0; i < args.length; i += 2) {
assert.ok(fields[args[i]] && options[fields[args[i]]] === undefined);
options[fields[args[i]]] = args[i + 1];
}
validate(options);
options.auth = JSON.parse(fs.readFileSync(options.authFile, 'utf8'));
fs.mkdirSync(options.outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
try { await runJourney(browser, options); } finally { await browser.close(); }
}
module.exports = { validate, judge, runJourney };
if (require.main === module) main().catch(() => { console.error('Release browser journey failed; inspect bounded receipt. No replay.'); process.exitCode = 1; });
@@ -0,0 +1,212 @@
/* Offline adversarial observer controls. These fixtures are not Pulse release,
* systemd, installed update, Tailscale or qualification evidence. */
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const { validate, judge, runJourney } = require('./release-browser-journey.cjs');
const from = 'v6.4.5', to = 'v6.4.6-rc.1';
const downloadUrl = `https://github.com/rcourtman/Pulse/releases/download/${to}/pulse-${to}-linux-amd64.tar.gz`;
function pureControls() {
const valid = { origin: 'https://ephemeral.tawny-powan.ts.net', mode: 'upgrade', from, to, expected: to };
validate(valid);
for (const origin of ['http://ephemeral.tawny-powan.ts.net', 'https://private.example.com',
'https://ephemeral.other.ts.net', 'https://ephemeral.tawny-powan.ts.net:443', 'https://ephemeral.tawny-powan.ts.net/path']) {
assert.throws(() => validate({ ...valid, origin }));
}
for (const delta of [{ mode: 'apply' }, { to: 'v6.4.6' }, { expected: from }]) assert.throws(() => validate({ ...valid, ...delta }));
const receipt = { ...valid, access_refused: false, login_status: 200, observed_version: to.slice(1), health: 'healthy',
protected_read_status: 200, apply_requests: 1, apply_status: 200, selected_version: to.slice(1),
sse_opens: 1, sse_progress: [{ status: 'downloading', progress: 10 }], progress_dialog_visible: false };
judge(receipt);
for (const delta of [{ access_refused: true }, { login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' },
{ protected_read_status: 403 }, { apply_requests: 0 }, { apply_requests: 2 }, { apply_status: 500 },
{ selected_version: '6.5.0-rc.1' }, { sse_opens: 0 }, { sse_progress: [] }, { progress_dialog_visible: true }]) {
assert.throws(() => judge({ ...receipt, ...delta }));
}
}
function fixture(kind) {
let version = from.slice(1), applied = false, denied = false;
const counters = { applies: 0, logins: 0, version_reads: 0, health_reads: 0,
protected_reads: 0, streams: 0, denials: 0, anonymous_probes: 0,
requests_after_denial: 0, transport_gaps: 0 };
const denial = /^(.*)-(401|403)$/.exec(kind);
const sockets = new Set(), timers = new Set();
const later = (fn, ms) => { const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms); timers.add(timer); };
const server = http.createServer((req, res) => {
if (denied) counters.requests_after_denial++;
const url = new URL(req.url, 'http://localhost');
const signedIn = /fixture-session=true/.test(req.headers.cookie || '');
const json = (code, data) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(data)); };
const refuse = endpoint => {
if (denial?.[1] !== endpoint) return false;
denied = true; counters.denials++;
json(Number(denial[2]), { error: 'DO NOT RETAIN FIXTURE PRIVATE DENIAL' });
return true;
};
if (url.pathname === '/api/login') {
counters.logins++;
if (refuse('login')) return;
res.setHeader('Set-Cookie', 'fixture-session=true; Path=/; HttpOnly; SameSite=Lax');
return json(200, { success: true });
}
if (url.pathname === '/api/security/status' || url.pathname === '/api/config/nodes' && !signedIn) {
counters.anonymous_probes++;
return json(url.pathname === '/api/security/status' ? 401 : 403, { error: 'normal anonymous auth probe' });
}
if (url.pathname === '/api/version') {
counters.version_reads++;
if (refuse(applied ? 'version' : 'initial-version')) return;
if (applied && kind === 'restart-gap' && counters.transport_gaps === 0) {
counters.transport_gaps++; res.destroy(); return;
}
return json(200, { version, isDocker: false, isSourceBuild: false });
}
if (url.pathname === '/api/health') {
counters.health_reads++;
if (refuse('health')) return;
return json(200, { status: 'healthy' });
}
if (url.pathname === '/api/config/nodes') {
counters.protected_reads++;
if (applied && refuse('read')) return;
return json(200, []);
}
if (url.pathname === '/api/settings/system') {
if (refuse('authenticated-api')) return;
return json(200, {});
}
if (url.pathname === '/api/cleanup-control') { refuse('cleanup'); return; }
if (url.pathname === '/api/updates/check') {
if (refuse('check')) return;
return json(200, { latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl });
}
if (url.pathname === '/api/updates/apply') {
counters.applies++;
if (refuse('apply')) return;
applied = true;
later(() => { version = to.slice(1); }, 350);
return json(200, { status: 'started' });
}
if (url.pathname === '/api/updates/stream') {
counters.streams++;
if (refuse('stream')) return;
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
res.write(': real loopback fixture stream\n\n');
if (kind !== 'silent-stream') res.write(`data: ${JSON.stringify({ status: 'downloading', progress: 10, message: 'DO NOT RETAIN FIXTURE PRIVATE MESSAGE' })}\n\n`);
req.on('close', () => res.end()); return;
}
if (refuse(signedIn ? 'authenticated-navigation' : 'navigation')) return;
res.writeHead(200, { 'content-type': 'text/html' });
res.end(`<!doctype html><html><head><title>Offline release observer control</title></head>
<body><h1>Observer fixture — not Pulse/native qualification</h1>${signedIn ? `
<h2>System updates</h2><button id="preview">Preview beta builds</button><button id="save">Save Changes</button>
<button id="check">Check for Updates</button><button id="install" hidden>Install Update</button>
<main id="dialogs"></main><p id="state">Published fixture ${version}</p>
<script>
document.querySelector('#save').onclick = async () => { await fetch('/api/settings/system', {method:'POST'}); };
document.querySelector('#check').onclick = async () => { await fetch('/api/updates/check?channel=rc'); document.querySelector('#install').hidden=false; };
document.querySelector('#install').onclick = () => {
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Confirm update"><h2>Confirm Update</h2><label><input type="checkbox"> Acknowledge</label><button id="start">Start Update</button></section>';
document.querySelector('#start').onclick = async () => {
const response = await fetch('/api/updates/apply', {method:'POST'}); if (!response.ok) return;
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Updating Pulse"><h2>Updating Pulse</h2><p>Downloading update 10%</p></section>';
const stream=new EventSource('/api/updates/stream');
setTimeout(() => { ${kind === 'stuck-modal' ? '' : "document.querySelector('#dialogs').innerHTML='';"}
document.querySelector('#state').textContent='Fixture ready'; stream.close(); }, 700);
};
};
</script>` : `
<input placeholder="Username"><input placeholder="Password" type="password"><button id="login">Sign in to Pulse</button>
<script>
const probes = ${kind === 'anonymous-probes' ? "Promise.all([fetch('/api/security/status'), fetch('/api/config/nodes')])" : 'Promise.resolve()'};
document.querySelector('#login').onclick = async () => { await probes; const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };
</script>`}</body></html>`);
});
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
return { server, counts: () => ({ ...counters }), close: async () => {
for (const timer of timers) clearTimeout(timer);
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
} };
}
async function runControls(outputDir) {
pureControls();
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
const results = [];
try {
const controls = [
['success', 'upgrade', true, 1, 1], ['success', 'recovery', true, 0, 1],
['anonymous-probes', 'upgrade', true, 1, 1], ['restart-gap', 'upgrade', true, 1, 1],
['wrong-preview', 'upgrade', false, 0, 1], ['silent-stream', 'upgrade', false, 1, 1],
['stuck-modal', 'upgrade', false, 1, 1],
['cleanup-401', 'upgrade', false, 1, 1], ['cleanup-403', 'upgrade', false, 1, 1],
];
for (const endpoint of ['navigation', 'authenticated-navigation', 'login', 'initial-version',
'authenticated-api', 'check', 'apply', 'stream', 'version', 'health', 'read']) {
for (const status of [401, 403]) {
controls.push([`${endpoint}-${status}`, 'upgrade', false,
['apply', 'stream', 'version', 'health', 'read'].includes(endpoint) ? 1 : 0,
endpoint === 'navigation' ? 0 : 1]);
}
}
for (const [kind, mode, expectedPass, expectedApplies, expectedLogins] of controls) {
const target = fixture(kind);
await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve));
const folder = path.resolve(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true });
let passed = false;
try {
// A real denied HTTP response deliberately arrives during cleanup,
// after the main checks. The observer must not leave a passed receipt.
// Only this fixture delays context disposal; no response is fabricated.
const testBrowser = kind.startsWith('cleanup-') ? {
version: () => browser.version(),
newContext: async options => {
const context = await browser.newContext(options);
const close = context.close.bind(context);
context.close = async () => {
await context.pages()[0].evaluate(() => fetch('/api/cleanup-control'));
await close();
};
return context;
},
} : browser;
await runJourney(testBrowser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to,
expected: mode === 'upgrade' ? to : from, auth: { username: 'synthetic', password: 'synthetic' },
timeout: 1500, readinessTimeout: 3000, outputDir: folder });
passed = true;
} catch (_) { /* observer must reject each deliberately adverse fixture */ }
finally { await target.close(); }
assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`);
const counts = target.counts();
assert.equal(counts.applies, expectedApplies, 'one apply, no replay');
assert.equal(counts.logins, expectedLogins, 'no login after navigation denial');
assert.equal(counts.requests_after_denial, 0, 'page stops after denial, including SSE reconnect');
const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`)));
assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE'));
assert.ok(!JSON.stringify(receipt).includes('synthetic'));
assert.equal(receipt.context_closed, true, 'browser context cleaned up');
const refusal = /-(401|403)$/.test(kind);
assert.equal(receipt.access_refused, refusal, 'only actual access refusal stops later access');
if (refusal) {
assert.equal(receipt.access_refusals[0].status, Number(kind.slice(-3)));
assert.equal(counts.denials, 1, 'denied request is not repeated');
assert.ok(!JSON.stringify(receipt).includes('PRIVATE DENIAL'));
}
if (kind.startsWith('read-')) assert.equal(receipt.protected_read_status, Number(kind.slice(-3)));
if (kind === 'anonymous-probes') assert.equal(counts.anonymous_probes, 2, 'normal pre-login denial controls ran');
if (kind === 'restart-gap') assert.equal(counts.transport_gaps, 1, 'restart transport control ran');
results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status,
active_step: receipt.active_step, ...counts, receipt });
}
fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ schema_version: 1, scope: 'offline adversarial observer controls only',
browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n');
console.log(JSON.stringify({ observer_controls: results.length, scope: 'fixtures, not native qualification', results }));
} finally { await browser.close(); }
}
module.exports = { runControls, pureControls };
if (require.main === module) runControls(path.resolve(process.argv[2] || 'tmp/browser-journey-proof')).catch(error => { console.error(error); process.exitCode = 1; });
@@ -121,7 +121,17 @@ Obtain them from the existing read-state, memory diagnostics and History APIs;
normalise only the target guest's fixed fields, without raw snapshots in public
evidence. Locked values/History cannot be renewed as fresh; CPU must progress.
Resumption needs available/agent memory, no disk deferral and fresh post-task
memory/disk History. **Internal cache timestamp preservation is source-tested;
memory/disk History. The complete command trace must also contain successful
`get-fsinfo` and `file-read` reads newly started after task completion and its
clock-uncertainty interval, and completed by the resumed readback within the
recorded clock allowance. Fresh History timestamps, independent filesystem
writes, the pre-lock overlap or metadata-only reads cannot substitute for those
post-task guest reads. Later polling cannot support an earlier resumed snapshot.
This is a consistency check: independently reconcile the raw command trace and
returned payloads with the target guest and History observations before judging
resumption. Do not send extra guest probes to make a record pass; observe normal
polling and retain an incomplete window as inconclusive.
**Internal cache timestamp preservation is source-tested;
these public readbacks do not directly inspect those internal timestamps.**
```sh
@@ -146,8 +156,11 @@ python3 -m unittest discover -s tests/qualification/guest-agent-backup -v
go test -race ./tests/qualification/guest-agent-backup/guest-writer -count=1
```
Use the assigned source-proof VM for worker execution. The real local writer
smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test.
Use the assigned source-proof VM for full source suites, builds and the Go
writer checks. The focused Python record/CLI controls use only synthetic local
files and can run locally without a native target or listener. They do not
validate the runtime lock guard or restore an interrupted executor attempt.
The real local writer smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test.
Do not replay unchanged guard/app suites to disguise the missing native target.
Delivery's next material result is verified native inputs and a completed (or
@@ -187,6 +187,16 @@ def check_record(record, root, expected_source):
resumed["memory_source"] == "guest-agent-meminfo" and resumed["disk_reason"] == "", "guest did not resume")
require(all(resumed[k] > backup["completed_ms"] + skew for k in ("disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")),
"fresh post-backup History absent")
# A fresh timestamp is not evidence of a new guest read. The complete
# native trace must include both kinds of reads supporting this resumed
# snapshot, not just the pre-lock in-flight command or an old cached result.
# Allow the recorded clock error when comparing completion with readback;
# do not accept a start inside the post-task uncertainty interval.
for method in ("get-fsinfo", "file-read"):
require(any(command["method"] == method and
command["start_ms"] > backup["completed_ms"] + skew and
command["end_ms"] <= resumed["at_ms"] + skew for command in commands),
"post-task " + method + " read supporting resumption missing")
witnesses = record["witnesses"]
require(type(witnesses) is list and 1 <= len(witnesses) <= 16, "filesystem witness bound")
@@ -59,7 +59,11 @@ def record():
"lock_end_ms": 106200, "completed_ms": 107000, "status": "OK", "trace_sha256": HASH},
"coverage": {"start_ms": 100000, "end_ms": 120000, "dropped_events": 0, "trace_sha256": HASH},
"commands": [{"id": "request-01", "method": "get-fsinfo", "start_ms": 103100,
"end_ms": 103500, "result": "ok"}],
"end_ms": 103500, "result": "ok"},
{"id": "resume-disk", "method": "get-fsinfo", "start_ms": 108100,
"end_ms": 108200, "result": "ok"},
{"id": "resume-memory", "method": "file-read", "start_ms": 108300,
"end_ms": 108400, "result": "ok"}],
"snapshots": [baseline, locked, {**locked, "at_ms": 105500, "cpu_latest_ms": 105000}, resumed],
"witnesses": []}
@@ -109,6 +113,90 @@ class BackupRecordTests(unittest.TestCase):
with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"):
self.check()
def test_resumed_history_and_writes_need_both_post_task_guest_reads(self):
original = copy.deepcopy(self.record["commands"])
for missing in (("resume-disk",), ("resume-memory",), ("resume-disk", "resume-memory")):
with self.subTest(missing=missing):
self.record["commands"] = [c for c in original if c["id"] not in missing]
with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"):
self.check()
def test_metadata_reads_cannot_support_resumed_memory_or_filesystems(self):
original = copy.deepcopy(self.record["commands"])
for index in (1, 2):
for method in ("info", "get-osinfo", "network-get-interfaces"):
with self.subTest(index=index, method=method):
self.record["commands"] = copy.deepcopy(original)
self.record["commands"][index]["method"] = method
with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"):
self.check()
def test_old_or_task_uncertain_reads_cannot_support_resumed_history(self):
original = copy.deepcopy(self.record["commands"])
# These all start after the lock clears, so the lock-dispatch oracle
# cannot reject them. Only a genuinely new post-task read may qualify.
completed = self.record["backup"]["completed_ms"]
skew = self.record["window"]["clock_error_ms"]
for method in ("get-fsinfo", "file-read"):
for began in (completed - 100, completed, completed + skew):
with self.subTest(method=method, began=began):
self.record["commands"] = [copy.deepcopy(original[0])]
self.record["commands"].extend([
{"id": "old-read", "method": method, "start_ms": began,
"end_ms": completed + 100, "result": "ok"},
{"id": "new-other-read", "method": "file-read" if method == "get-fsinfo" else "get-fsinfo",
"start_ms": 108100, "end_ms": 108200, "result": "ok"},
])
with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"):
self.check()
def test_future_reads_cannot_support_an_earlier_resumed_snapshot(self):
original = copy.deepcopy(self.record["commands"])
at = self.record["snapshots"][-1]["at_ms"]
skew = self.record["window"]["clock_error_ms"]
for index in (1, 2):
for began, finished in ((at - 100, at + skew + 1), (at + 100, at + 200)):
with self.subTest(index=index, began=began, finished=finished):
self.record["commands"] = copy.deepcopy(original)
self.record["commands"][index].update(start_ms=began, end_ms=finished)
with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"):
self.check()
def test_post_task_read_clock_boundaries_and_later_polling_remain_valid(self):
original = copy.deepcopy(self.record)
completed = self.record["backup"]["completed_ms"]
at = self.record["snapshots"][-1]["at_ms"]
for skew in (0, 10, 1000):
with self.subTest(skew=skew):
self.record = copy.deepcopy(original)
self.record["window"]["clock_error_ms"] = skew
# Give the maximum-skew control enough independent margins
# for baseline, overlap, lock-time snapshots and liveness too.
if skew == 1000:
self.record["backup"].update(lock_start_ms=103000, freeze_ms=103100,
thaw_request_ms=106500, lock_end_ms=106900)
self.record["commands"][0].update(start_ms=101100, end_ms=104100)
self.record["snapshots"][0]["at_ms"] = 101500
self.record["snapshots"][1]["at_ms"] = 104100
self.record["snapshots"][2]["at_ms"] = 105800
self.record["commands"][1].update(start_ms=completed + skew + 1, end_ms=completed + skew + 2)
self.record["commands"][2].update(start_ms=at - 10, end_ms=at + skew)
self.record["commands"].append({"id": "later-poll", "method": "file-read", "start_ms": 115000,
"end_ms": 115100, "result": "ok"})
self.assertTrue(self.check()["record_checks_passed"])
def test_cli_rejects_unsupported_resumption_without_native_acceptance_claim(self):
self.record["commands"] = self.record["commands"][:1]
path = self.root / "record.json"
path.write_text(json.dumps(self.record))
result = subprocess.run([sys.executable, str(Path(__file__).with_name("check.py")),
"--record", str(path), "--expected-source", SOURCE], capture_output=True, text=True)
self.assertEqual(result.returncode, 1)
failure = json.loads(result.stderr)
self.assertFalse(failure["record_checks_passed"])
self.assertFalse(failure["native_acceptance_complete"])
self.assertEqual(failure["failure"], "post-task get-fsinfo read supporting resumption missing")
def test_wrong_id_timeout_and_incomplete_native_commands_stay_adverse(self):
for result in ("wrong-command-id", "timeout", "response-incomplete", "permission-denied"):
with self.subTest(result=result):