mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+261
@@ -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) => {
|
||||
|
||||
+5
-5
@@ -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) => {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user