diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f28df761e2..6d2244bf50 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -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` diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml new file mode 100644 index 0000000000..cf79766ba5 --- /dev/null +++ b/.github/workflows/qualify-browser-update-release.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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 190e1d6b93..409cbc8bf6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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. --- diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 70e326c93e..872dd58ec1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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.
-Manual systemd install (advanced) +Manual or custom systemd services (advanced) + +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. +
--- @@ -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. diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 54dac2b6e6..270d15e838 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0abcdb1188..a04ff4da33 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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`) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b9e77e82f5..ca63ff380d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 105a8ff229..fbb0f07eb6 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index bc2298fb1b..1b95e57542 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 109e1ad0bb..73db5c65df 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx index 5c11587d77..373a4dd878 100644 --- a/frontend-modern/browser-tests/docs-fragment-navigation.tsx +++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx @@ -22,6 +22,9 @@ const targets: Record = { 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); diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.cjs b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs new file mode 100644 index 0000000000..7ecac9c08a --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.html b/frontend-modern/browser-tests/filesystem-usage-evidence.html new file mode 100644 index 0000000000..d05f948fed --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.html @@ -0,0 +1,12 @@ + + + + + + Filesystem usage evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.tsx b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx new file mode 100644 index 0000000000..0c6caa0395 --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx @@ -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; 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(() => ({ + 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 ( +
+

Filesystem usage evidence

+ +
+ +
+
+ +
+
+ ); +} + +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-tests/guest-history-provenance.cjs b/frontend-modern/browser-tests/guest-history-provenance.cjs new file mode 100644 index 0000000000..66a428810b --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/guest-history-provenance.html b/frontend-modern/browser-tests/guest-history-provenance.html new file mode 100644 index 0000000000..f4fad04d47 --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.html @@ -0,0 +1,12 @@ + + + + + + Guest History filesystem provenance verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-history-provenance.tsx b/frontend-modern/browser-tests/guest-history-provenance.tsx new file mode 100644 index 0000000000..60400436e4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.tsx @@ -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(() => ({ + 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).__guestHistoryProvenance = { + observe: (next: Partial>) => + setObservation((prev) => ({ ...prev, ...next })), + deferrals: guestDiskDeferrals, + }; + return ( +
+

Guest History filesystem provenance

+

+ Synthetic observations. No backup or guest-agent command is sent. +

+
+ {}} /> +
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/server-removal-help.cjs b/frontend-modern/browser-tests/server-removal-help.cjs new file mode 100644 index 0000000000..a13c0bdabe --- /dev/null +++ b/frontend-modern/browser-tests/server-removal-help.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/systemd-install-help.cjs b/frontend-modern/browser-tests/systemd-install-help.cjs new file mode 100644 index 0000000000..e4cf19a301 --- /dev/null +++ b/frontend-modern/browser-tests/systemd-install-help.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/token-guidance.cjs b/frontend-modern/browser-tests/token-guidance.cjs new file mode 100644 index 0000000000..e51b9b5da6 --- /dev/null +++ b/frontend-modern/browser-tests/token-guidance.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/truenas-connection-help.cjs b/frontend-modern/browser-tests/truenas-connection-help.cjs new file mode 100644 index 0000000000..3a6e38541a --- /dev/null +++ b/frontend-modern/browser-tests/truenas-connection-help.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/workload-read-evidence.cjs b/frontend-modern/browser-tests/workload-read-evidence.cjs new file mode 100644 index 0000000000..69fc5654bc --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/workload-read-evidence.html b/frontend-modern/browser-tests/workload-read-evidence.html new file mode 100644 index 0000000000..2789b59804 --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.html @@ -0,0 +1,12 @@ + + + + + + Workload read evidence fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/workload-read-evidence.tsx b/frontend-modern/browser-tests/workload-read-evidence.tsx new file mode 100644 index 0000000000..c3d28bcbcc --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.tsx @@ -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( + 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 ( +
+

Workload read evidence

+

+ Synthetic {transport} observations. No guest commands are sent. +

+ +
+ + + + +
+
+
+ {}} /> +
+
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/d5b903be5d5f5368.json b/frontend-modern/browser-verification/d5b903be5d5f5368.json new file mode 100644 index 0000000000..edab379a9c --- /dev/null +++ b/frontend-modern/browser-verification/d5b903be5d5f5368.json @@ -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." + ] +} diff --git a/frontend-modern/browser-verification/d92d8a69db1a806f.json b/frontend-modern/browser-verification/d92d8a69db1a806f.json new file mode 100644 index 0000000000..dd08ed8a99 --- /dev/null +++ b/frontend-modern/browser-verification/d92d8a69db1a806f.json @@ -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." + ] +} diff --git a/frontend-modern/browser-verification/fe7c10feafce0f66.json b/frontend-modern/browser-verification/fe7c10feafce0f66.json new file mode 100644 index 0000000000..1345a0cced --- /dev/null +++ b/frontend-modern/browser-verification/fe7c10feafce0f66.json @@ -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." + ] +} diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 190e1d6b93..409cbc8bf6 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -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. --- diff --git a/frontend-modern/public/docs/INSTALL.md b/frontend-modern/public/docs/INSTALL.md index 70e326c93e..872dd58ec1 100644 --- a/frontend-modern/public/docs/INSTALL.md +++ b/frontend-modern/public/docs/INSTALL.md @@ -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.
-Manual systemd install (advanced) +Manual or custom systemd services (advanced) + +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. +
--- @@ -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. diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index 54dac2b6e6..270d15e838 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -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 diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 47d2960bba..a357439143 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -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(() => ); + 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( diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index 291b30525e..1439f7a212 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -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 = (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 = (props) => { target={historyTarget()} range={historyRange()} currentMetrics={historyCurrentMetrics()} + deferredMetrics={historyDeferredMetrics()} /> )} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index bf2b5246bc..c8881b9407 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -41,12 +41,14 @@ import { getGuestDrawerHistoryValueLabel, normalizeGuestDrawerHistoryPoints, type GuestDrawerHistoryGroupConfig, + type GuestDrawerHistoryDeferredMetric, type GuestDrawerHistoryTarget, type GuestDrawerHistoryTimeBounds, } from './guestDrawerModel'; interface GuestDrawerHistoryProps { currentMetrics?: Record; + deferredMetrics?: Record; groups?: GuestDrawerHistoryGroupConfig[]; range: HistoryTimeRange; target: GuestDrawerHistoryTarget | null; @@ -65,6 +67,7 @@ interface GuestDrawerHistoryQueryKey { interface GuestDrawerHistoryGroupChartProps { currentMetrics?: Record; + deferredMetrics?: Record; group: GuestDrawerHistoryGroupConfig; loading: boolean; metrics: Record; @@ -309,24 +312,31 @@ const GuestDrawerHistoryGroupChart: Component 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 current + + last known + )} + item.deferred)}> + {(item) => ( +

+ {item.label} live reading: {item.deferred?.message} +

+ )} +
+
{(tick) => ( @@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component = (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()} diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx index b11f2534e1..441f7b36e7 100644 --- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx @@ -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(() => ); + 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); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx new file mode 100644 index 0000000000..6c1bcb45e5 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx @@ -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(() => ( + + )); +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); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx index 84e17dd262..5ca01afbf3 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx @@ -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(() => ( + + )); + 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'], diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 362d24e6e5..e944724eb3 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -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(() => ( + + + + +
+ )); + 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) => { diff --git a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts index a5d0882717..f112dc6a29 100644 --- a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts @@ -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', diff --git a/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx new file mode 100644 index 0000000000..4ffbe725b5 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx @@ -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 => ({ + mountpoint: '/data', + type: 'ext4', + total: capacity, + used: capacity / 2, + usage: 50, + ...overrides, +}); + +const unavailable: [string, Partial][] = [ + ['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(() => ( + <> +
+ +
+ + + )); + 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][])( + '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'); + }); +}); diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts index 15da466a7d..61ba4c3d28 100644 --- a/frontend-modern/src/components/Workloads/diskListModel.ts +++ b/frontend-modern/src/components/Workloads/diskListModel.ts @@ -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() ?? ''; diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 88fcafee6b..47ba99773e 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -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 => { + 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= 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 { diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index 6034b22b98..7b5d58bfc5 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -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); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index fc20e938e7..57f864710f 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -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; + 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; + 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 = {}, 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; + 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) => { diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 89e6eeb684..56ca84945c 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -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, diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index bfe282cb05..a08c2189a0 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -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('Manual or custom systemd services (advanced)')[1] + .split('')[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'), diff --git a/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts b/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts new file mode 100644 index 0000000000..c55c5ccf69 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts @@ -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); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index da7e9dbfba..d38b96df74 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -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]); diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 8315e0e87f..58408371bf 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -122,6 +122,31 @@ const mergeRecord = (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 = (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 = ( ...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; } diff --git a/internal/monitoring/guest_agent_backup_integration_test.go b/internal/monitoring/guest_agent_backup_integration_test.go index 3afe1c8a27..349103ec48 100644 --- a/internal/monitoring/guest_agent_backup_integration_test.go +++ b/internal/monitoring/guest_agent_backup_integration_test.go @@ -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) } diff --git a/internal/monitoring/metric_window_provider_test.go b/internal/monitoring/metric_window_provider_test.go index e29c52935a..ef74ee88d4 100644 --- a/internal/monitoring/metric_window_provider_test.go +++ b/internal/monitoring/metric_window_provider_test.go @@ -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{ diff --git a/internal/monitoring/metrics_history.go b/internal/monitoring/metrics_history.go index ba4ab659b8..928356ced2 100644 --- a/internal/monitoring/metrics_history.go +++ b/internal/monitoring/metrics_history.go @@ -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) diff --git a/internal/monitoring/metrics_history_concurrency_test.go b/internal/monitoring/metrics_history_concurrency_test.go index 2417496a23..f9dc001237 100644 --- a/internal/monitoring/metrics_history_concurrency_test.go +++ b/internal/monitoring/metrics_history_concurrency_test.go @@ -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+ diff --git a/internal/monitoring/metrics_history_memory_regression_test.go b/internal/monitoring/metrics_history_memory_regression_test.go index 815120e0f6..e78f890b5d 100644 --- a/internal/monitoring/metrics_history_memory_regression_test.go +++ b/internal/monitoring/metrics_history_memory_regression_test.go @@ -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)) + }) + } +} diff --git a/internal/monitoring/metrics_history_test.go b/internal/monitoring/metrics_history_test.go index 153e949a9d..bfa5d59ad8 100644 --- a/internal/monitoring/metrics_history_test.go +++ b/internal/monitoring/metrics_history_test.go @@ -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() diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index dec68ba461..12750b34e0 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -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 } diff --git a/pkg/proxmox/guest_agent_lock_response_test.go b/pkg/proxmox/guest_agent_lock_response_test.go new file mode 100644 index 0000000000..58f5cd0083 --- /dev/null +++ b/pkg/proxmox/guest_agent_lock_response_test.go @@ -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) + } +} diff --git a/pkg/proxmox/io_counters_test.go b/pkg/proxmox/io_counters_test.go index fc210c76ab..1354c757e6 100644 --- a/pkg/proxmox/io_counters_test.go +++ b/pkg/proxmox/io_counters_test.go @@ -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) + } +} diff --git a/scripts/release_browser_packet.py b/scripts/release_browser_packet.py new file mode 100644 index 0000000000..3f1cffd8a9 --- /dev/null +++ b/scripts/release_browser_packet.py @@ -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() diff --git a/scripts/release_browser_tailnet.py b/scripts/release_browser_tailnet.py new file mode 100644 index 0000000000..9068d0f96a --- /dev/null +++ b/scripts/release_browser_tailnet.py @@ -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()) diff --git a/scripts/release_lifecycle_rehearsal.sh b/scripts/release_lifecycle_rehearsal.sh index 100fc33889..4818f168dc 100755 --- a/scripts/release_lifecycle_rehearsal.sh +++ b/scripts/release_lifecycle_rehearsal.sh @@ -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 diff --git a/scripts/test-vm-disk.sh b/scripts/test-vm-disk.sh index b6464e7b94..b386aa8c4c 100755 --- a/scripts/test-vm-disk.sh +++ b/scripts/test-vm-disk.sh @@ -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 diff --git a/scripts/tests/test_release_browser_packet.py b/scripts/tests/test_release_browser_packet.py new file mode 100644 index 0000000000..2f1e1f3fe7 --- /dev/null +++ b/scripts/tests/test_release_browser_packet.py @@ -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() diff --git a/scripts/tests/test_release_browser_tailnet.py b/scripts/tests/test_release_browser_tailnet.py new file mode 100644 index 0000000000..8c7f64d8f1 --- /dev/null +++ b/scripts/tests/test_release_browser_tailnet.py @@ -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() diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index b778c40c2e..00131f2f5b 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -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() diff --git a/scripts/tests/test_truenas_docs.py b/scripts/tests/test_truenas_docs.py index 385edb1a19..0b24f1f62f 100644 --- a/scripts/tests/test_truenas_docs.py +++ b/scripts/tests/test_truenas_docs.py @@ -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, diff --git a/scripts/tests/test_vm_disk_diagnostics.py b/scripts/tests/test_vm_disk_diagnostics.py index 3e25f290fb..adce1fb149 100644 --- a/scripts/tests/test_vm_disk_diagnostics.py +++ b/scripts/tests/test_vm_disk_diagnostics.py @@ -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"]): diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index aeef6481a2..fb8ad4944a 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -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: diff --git a/tests/integration/scripts/release-browser-journey.cjs b/tests/integration/scripts/release-browser-journey.cjs new file mode 100644 index 0000000000..1cb3c280fd --- /dev/null +++ b/tests/integration/scripts/release-browser-journey.cjs @@ -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; }); diff --git a/tests/integration/scripts/release-browser-journey.test.cjs b/tests/integration/scripts/release-browser-journey.test.cjs new file mode 100644 index 0000000000..8a20120df5 --- /dev/null +++ b/tests/integration/scripts/release-browser-journey.test.cjs @@ -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(`Offline release observer control +

Observer fixture — not Pulse/native qualification

${signedIn ? ` +

System updates

+ +

Published fixture ${version}

+ ` : ` + + `}`); + }); + 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; }); diff --git a/tests/qualification/guest-agent-backup/README.md b/tests/qualification/guest-agent-backup/README.md index ca0eff9002..b4018d2d61 100644 --- a/tests/qualification/guest-agent-backup/README.md +++ b/tests/qualification/guest-agent-backup/README.md @@ -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 diff --git a/tests/qualification/guest-agent-backup/check.py b/tests/qualification/guest-agent-backup/check.py index 076e86262c..f7788fba46 100644 --- a/tests/qualification/guest-agent-backup/check.py +++ b/tests/qualification/guest-agent-backup/check.py @@ -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") diff --git a/tests/qualification/guest-agent-backup/test_check.py b/tests/qualification/guest-agent-backup/test_check.py index 8dc9a98fd6..7f7919bc72 100644 --- a/tests/qualification/guest-agent-backup/test_check.py +++ b/tests/qualification/guest-agent-backup/test_check.py @@ -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):