mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Make setup and administration safer and show sparse History honestly (#2362)
## What changed and why This batch repairs existing setup, administration and History surfaces: - PBS and agent guides keep tokens out of shell history and process arguments, prefer read-only identities, and require verified HTTPS downloads saved for inspection. Fleet/action, RBAC, audit and organisation examples use private header files, supported schemas, signed-in session-only mutations, custom-role operations and private exports. - Generated Proxmox setup separates credential reveal from copied commands and uses private-file handoffs. Download/preflight, permission, identity and cleanup boundaries remain intact. - Shared tables regain first-touch activation without swallowing embedded controls or keyboard disclosure. Sparse drawer History displays real stored observations without inventing trends, labels current fallbacks honestly, and explains empty windows. - Demo chart series reuse graph-version-bound metric samplers rather than rebuilding the whole estate for every series, preserving values and isolation. - Release-note validation accepts authored grouped notes while retaining identity and safety checks; qualification binds the complete current source rather than an incomplete filesystem list. These are repairs to existing behaviour and unsafe or inaccurate guidance, not new features or permissions. PBS source/browser improvements do not establish complete installed resolution of the mixed #1723 report. ## Included source Complete unpublished `pulse` range `e5082c5fb1be8f8882275338d8b9f088b6a41cc4..f047d7b5db78ef1e3ca0b84de4fe2f6c43a9311c`. This extends the former PR #2362 head `704f4eeb4ef4f29cb0341d847665516b315cc24a`, whose workflows have completed, rather than restarting an actively checking proposal. Reviewed candidates and their additive integrations: - PBS guidance `a5c8b5d59799c7cf43af0f0fc7e796722c95fe7f`, integrated at `6a47dad9ce60a3814ad08de0814dff84493b5453`. - Touch-row candidate `88f52387af1fe6ebd6554acd49320a2e5927c0e8` and lint correction `12c8a7aba39bd8457f541ca0b1cc045893aed5db`, integrated at `36418af66beac94204276f7dc73de2c41e297fb1`. - Agent guidance `d5c187613a68e6e891ae9a5b6dbfd6154cea1844`, integrated at `9bbbc7a817274ab13b9c20b2119f46edd94d2109`; fleet/action guidance `704f4eeb4ef4f29cb0341d847665516b315cc24a`. - Sparse History candidate `b405f8d99b252bfc0e8773914fe07c96e2ab7f59`, retaining runtime `ec372845541fd0a79800b9ccadd7f4972be5e836`, integrated at `a5347278430342fd805e758897c00ce187579d66`. - Release-note/qualification candidate `90c80acb493eb4ff6a789b184a794b87b4a6d538`, integrated at `22380a35460a255ac4d60e20549e959875ccbc21`. - Private-bootstrap/demo sampler candidate `cf12c37e6302883e66039078d6e504b1014b26cc`, integrated at `0832be112f024f5a6dd695da42b134e9039c2840`. - Administration guidance `0c3cd4fb3ad1a32cb7ddb00b0992a53991594b79` (three own commits), integrated at `7d5c6eb0045f94481ae3147476187e9ad2eeccda`. - Additive corrections `0fd47f7ebf5da8588d62b9f81b336b5267e3430d` and `5116c2a2d9e49e83e794d23cf99e40c240e51ad9` reconcile the PBS guard and explicit TLS fixture floor with accepted safe guidance and current release direction. - Upstream merges `f5e305d4c9494830743a140bbb1f951694555c38` and `f047d7b5db78ef1e3ca0b84de4fe2f6c43a9311c` retain published ancestry, including upstream's independent shadow lifecycle rehearsal. No accepted identity was rewritten. ## Validation and adverse evidence Sufficient existing candidate evidence is reused, with fresh bounded reconciliation checks: - PBS `source-1bj5m4w1`: 28 checks; frontend documentation/build/budgets `source-dz1jcq79`. Fleet/action `source-cb0mkbhb`: nine executable guide tests; frontend `source-qynvs3_n`, 32 browser checks/four inspected screenshots. Agent guidance has six executable fixture tests, including private-file permissions and filename-only handoffs. - Touch candidate: four WebKit/Chromium/Firefox phone/desktop cases, embedded-control and keyboard assertions, 12 inspected screenshots. Sparse History final `source-nha4kbvo`: formatting and 121 affected tests; predecessor full type/lint/test/build/budget proof and four browser passes/32 inspected screenshots. Parallel `source-_d931osk` failed one existing window-test timeout; that result remains adverse, not an overall pass. - Bootstrap/demo `source-rlyqg2wg`: full affected configapi/hostagent/mock/monitoring/installtests race suites and affected API cases; `source-p2vo7bpw`: affected vet and actual frontend/server/agent builds. `source-l681cvrh` records frontend type-check and 21,612 tests; browser matrix covers PVE/PBS desktop and emulated phones with eight inspected screenshots. Earlier full API timeout and output-limit attempts remain unresolved, not passing receipts. - Release-note closure `source-ig3ted7j`: complete install suite and 942 Python cases/eight skips on its recorded source. Final fixture correction `source-in4fqcsx` passes authored-note tests and seven negative safety/identity subcases. - Administration `source-_klnz0rf`: 55 documentation tests, 26 API tests, authentication tests and documentation audits. Frontend `source-lrr0gmox`: 50 documentation tests/build/budgets; 41 browser checks/eight inspected images. Fresh full-range provenance and whitespace pass. All 23 unpublished non-merge commits pass the prescribed per-commit completion/browser checks using their recorded Contract-Neutral reasons where applicable; aggregate browser coverage binds all six changed frontend runtime paths. All 62 shipped-doc mirrors and 211 public Markdown checks pass. A naive aggregate shape check fails because it cannot honour those per-commit reasons; the workflow-prescribed per-commit check passes. The repaired PBS required/forbidden source assertions were separately checked against the final text. An optional additional source-VM check was cancelled while queued, before execution: no receipt or pass is claimed. Former-head CI runs `36829661316` and `36829661309` failed the retired credential-bearing PBS assertion; the accepted additive corrections replace it. The former CodeQL finding concerned the local TLS fixture's implicit protocol floor, now explicit. Former advisory benchmark `ConcurrentReadWrite-4` remains failed: 906.4→1007.7 µs, +11.17%, p=.011, n=10. Its former candidate and base have identical Go sources/manifests, but this does not prove the cause or turn the measurement green. The observation is retained for independent Delivery assessment; fresh exact-head CI remains necessary for the expanded batch. Required exact-head checks and independent review govern landing. These are synthetic/source proofs, not physical-device acceptance, native installation/recovery, complete PBS reporter acceptance, deployed activation, release qualification or availability in a release. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
+131
-71
@@ -34,7 +34,7 @@ X-API-Token: <token>
|
||||
Then make a read-only request (curl 7.76 or later):
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/state/summary
|
||||
```
|
||||
|
||||
@@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled;
|
||||
do not use `--insecure`. On an authentication-enabled instance, the protected
|
||||
summary checks token access; `/api/health` is public and does not verify authentication.
|
||||
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
|
||||
403, while retaining the error response.
|
||||
403, while retaining the error response. Keep `--disable` first: it ignores
|
||||
local curl configuration that could otherwise enable credential-bearing trace output.
|
||||
|
||||
Do not paste tokens into command lines, URLs or issue reports. Keep the header
|
||||
file private and outside shared repositories and diagnostics; do not use curl
|
||||
@@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include:
|
||||
- `settings:write`
|
||||
- `agent:config:read`
|
||||
- `agent:manage`
|
||||
- `actions:plan`, `actions:approve`, `actions:execute`
|
||||
- `audit:read`
|
||||
|
||||
Endpoints that require admin access are noted below.
|
||||
|
||||
@@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta
|
||||
|
||||
The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels.
|
||||
|
||||
CLI adapter:
|
||||
Use the private header file prepared in [Authentication](#-authentication),
|
||||
with `settings:read` on the token:
|
||||
|
||||
```bash
|
||||
PULSE_API_TOKEN=your-token pulse fleet connections \
|
||||
--api-url http://localhost:7655
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/connections
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
`POST /api/actions/plan`
|
||||
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
|
||||
|
||||
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
|
||||
The existing action API separates planning, approval and execution. Run these
|
||||
steps separately, inspecting each response before moving on; do not paste the
|
||||
whole sequence as an unattended recovery script. Use the private header file
|
||||
from [Authentication](#-authentication), not a token in a command or environment
|
||||
assignment. For remote access, replace the loopback origin with your Pulse HTTPS
|
||||
URL and keep certificate verification enabled.
|
||||
|
||||
`POST /api/actions/{id}/decision`
|
||||
Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action.
|
||||
| Endpoint | Token scope and access |
|
||||
| --- | --- |
|
||||
| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. |
|
||||
| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. |
|
||||
| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. |
|
||||
| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. |
|
||||
| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. |
|
||||
| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. |
|
||||
|
||||
`POST /api/actions/{id}/execute`
|
||||
Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint.
|
||||
The legacy `ai:execute` scope also permits planning, approval and execution,
|
||||
but use the narrower action scopes when possible. Approval and execution tokens
|
||||
must be bound to an authorised user; a scope alone does not bypass approval
|
||||
policy, separation of duties or step-up requirements. Audit reads through a
|
||||
browser session additionally require admin access. A `monitoring:read` token is
|
||||
not an action-control or audit token.
|
||||
|
||||
1. **Inspect capabilities.** Choose the canonical resource `id` from
|
||||
`GET /api/resources`, not a display name or a VM number. Replace `vm:42`
|
||||
throughout these examples and URL-encode it in request paths (`vm%3A42` here).
|
||||
An empty capabilities list means there is nothing to plan for that resource.
|
||||
|
||||
CLI adapter:
|
||||
```bash
|
||||
PULSE_API_TOKEN=your-token pulse actions capabilities \
|
||||
--api-url http://localhost:7655 \
|
||||
--resource-id vm:42
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions plan \
|
||||
--api-url http://localhost:7655 \
|
||||
--request-id agent-run-123 \
|
||||
--resource-id vm:42 \
|
||||
--capability restart \
|
||||
--param mode=graceful \
|
||||
--reason "Recover after confirmed outage" \
|
||||
--requested-by agent:oncall-helper
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions decide \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_... \
|
||||
--outcome approved \
|
||||
--reason "Inside maintenance window"
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions execute \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_... \
|
||||
--reason "Execute approved recovery"
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions audit \
|
||||
--api-url http://localhost:7655 \
|
||||
--resource-id vm:42 \
|
||||
--limit 10
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions events \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_...
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
|
||||
```
|
||||
|
||||
Request:
|
||||
```json
|
||||
2. **Plan only.** Use an advertised capability and its actual parameter schema;
|
||||
`restart` and `mode=graceful` are examples, not universal capabilities. Choose
|
||||
a request ID for this specific intent. Pulse derives the actor from the
|
||||
authenticated credential, not a caller-supplied `requestedBy` value.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/plan <<'JSON'
|
||||
{
|
||||
"requestId": "agent-run-123",
|
||||
"requestId": "manual-recovery-123",
|
||||
"resourceId": "vm:42",
|
||||
"capabilityName": "restart",
|
||||
"params": { "mode": "graceful" },
|
||||
"reason": "Recover after confirmed outage",
|
||||
"requestedBy": "agent:oncall-helper"
|
||||
"reason": "Recover after confirmed outage"
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Inspect the returned approval policy, blast radius, expiry and preflight checks.
|
||||
Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...`
|
||||
below with those returned values. Planning is not proof that execution is safe
|
||||
or currently available.
|
||||
|
||||
3. **Decide only if approval is required.** Use an authorised approver's private
|
||||
header file. Approve only the reviewed plan; stop on a refusal or stale-plan
|
||||
response rather than weakening the policy or creating another recovery action.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON'
|
||||
{
|
||||
"outcome": "approved",
|
||||
"reason": "Inside maintenance window",
|
||||
"planHash": "sha256:..."
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
4. **Execute separately.** Only after reviewing a successful decision, or an
|
||||
approval-free executable plan, use the executor's authorised header file:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON'
|
||||
{
|
||||
"reason": "Execute approved recovery",
|
||||
"planHash": "sha256:..."
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
If the response is lost or times out, inspect the existing action and target
|
||||
before retrying: losing the connection does not prove that execution stopped.
|
||||
An HTTP success is not a substitute for checking the terminal result and the
|
||||
resource's observed state.
|
||||
|
||||
5. **Read the audit and events**, using a private header file with audit access:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/actions/act_.../events
|
||||
```
|
||||
|
||||
When audit logging is unavailable, use Pulse's action detail in the signed-in
|
||||
UI to inspect the existing action; do not infer success or repeat execution
|
||||
from an unavailable audit response.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"actionId": "act_...",
|
||||
"requestId": "agent-run-123",
|
||||
"requestId": "manual-recovery-123",
|
||||
"allowed": true,
|
||||
"requiresApproval": true,
|
||||
"approvalPolicy": "admin",
|
||||
@@ -525,9 +580,11 @@ Request body:
|
||||
### Setup Script (Public)
|
||||
`GET /api/setup-script`
|
||||
Returns the Proxmox/PBS setup script as a shell-script download. Accepts an
|
||||
optional temporary setup token in the `setup_token` query for embedded
|
||||
non-interactive bootstrap; otherwise the script prompts for the one-time setup
|
||||
token at runtime. Canonical callers must send a supported `type` of `pve` or
|
||||
optional legacy `setup_token` query for compatibility. Current downloads
|
||||
contain no token: paste the separately revealed token only at the silent
|
||||
terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600
|
||||
regular file in a mode-0700 directory owned by the script's user. Never put a
|
||||
token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or
|
||||
`pbs` plus non-empty `host` and `pulse_url`; the route no longer generates
|
||||
placeholder-host scripts for later repair or reconstructs Pulse identity from
|
||||
the request origin. The route now shares the same canonical type boundary as
|
||||
@@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the
|
||||
setup/bootstrap path derive from the canonical Pulse endpoint, not request-local
|
||||
host fallbacks, so setup-script and turnkey node-add flows stay on one
|
||||
deterministic `pulse-<canonical-scope-slug>` identity per Pulse instance.
|
||||
`setupToken` remains bootstrap transport data for `/api/setup-script` and
|
||||
`/api/auto-register`, while `tokenHint` is the operator-facing display field
|
||||
for quick-setup surfaces and must stay masked instead of exposing the full
|
||||
one-time token in UI copy. Shared frontend consumers may validate
|
||||
`setupToken`, but they should not retain or display it once the returned
|
||||
bootstrap artifact and `tokenHint` are available; visible quick-setup previews
|
||||
should use the non-secret `commandWithoutEnv` form while copy actions keep
|
||||
using the token-bearing `commandWithEnv` artifact, and manual download flows
|
||||
should use the token-bearing `downloadURL` artifact instead of rebuilding a
|
||||
plain setup-script URL from non-secret preview state. Non-frontend bootstrap
|
||||
consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that
|
||||
same full artifact contract too, rejecting missing or mismatched
|
||||
`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a
|
||||
reduced setup-token-only response shape.
|
||||
The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain
|
||||
identical credential-free commands. They download the complete script before
|
||||
running it and prompt silently in the root-or-sudo process. The token crosses
|
||||
the installer boundary through a private file, not process arguments or an
|
||||
exported secret. `downloadURL` equals the tokenless `url`, so a manual download
|
||||
also needs the separately revealed token at runtime. `setupToken` is used for
|
||||
`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings
|
||||
reveals the token in a separate dialog: run the command first, then copy and
|
||||
paste the token only at its prompt. The artifact is reused only for the same
|
||||
host and options while its five-minute expiry is live, and discarded when the
|
||||
setup modal closes.
|
||||
Non-frontend consumers must validate the complete artifact, including the
|
||||
canonical host, type, URLs, filename, masked hint and live expiry. Current
|
||||
Unified Agents and the shell installer also accept the coherent older-server
|
||||
artifact during upgrades, but never execute its command text. For new
|
||||
Proxmox agent enrolment against a newer server, use its current installer;
|
||||
already enrolled agents keep reporting normally.
|
||||
|
||||
### Auto-Register (Public)
|
||||
`POST /api/auto-register`
|
||||
@@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user.
|
||||
```json
|
||||
{ "id": "acme-corp", "displayName": "Acme Corporation" }
|
||||
```
|
||||
The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters.
|
||||
The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example.
|
||||
|
||||
### Get Organization
|
||||
`GET /api/orgs/{id}` (requires `settings:read`)
|
||||
@@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org.
|
||||
```json
|
||||
{ "userId": "jane", "role": "editor" }
|
||||
```
|
||||
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
|
||||
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
|
||||
|
||||
### Remove Member
|
||||
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
|
||||
@@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations.
|
||||
"accessRole": "viewer"
|
||||
}
|
||||
```
|
||||
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
|
||||
Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
|
||||
|
||||
### Delete Share
|
||||
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)
|
||||
|
||||
+30
-12
@@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
|
||||
|
||||
### API
|
||||
|
||||
Use a token with `audit:read`, bound to a user permitted to read audit logs,
|
||||
and the licensed `audit_logging` capability. Prepare the private header file
|
||||
in [API authentication](API.md#-authentication); never paste the token or a
|
||||
session cookie into a command. For a one-off read, you can instead open the
|
||||
API path in your signed-in Pulse browser.
|
||||
|
||||
The loopback URLs below apply on the Pulse host. For remote access, use your
|
||||
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
|
||||
keeping `--disable` first to ignore local trace/verbose defaults.
|
||||
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
|
||||
402 and 403. Share only the relevant redacted error, not the header file or
|
||||
whole audit response: events can contain usernames, client addresses and paths.
|
||||
|
||||
```bash
|
||||
# List recent events
|
||||
curl http://localhost:7655/api/audit?limit=50 \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit?limit=50'
|
||||
|
||||
# Filter by event type and date range
|
||||
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
|
||||
|
||||
# Get audit summary
|
||||
curl http://localhost:7655/api/audit/summary \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/summary
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
@@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
|
||||
Export the audit log for external analysis or compliance archival:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/audit/export \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o audit-export.json
|
||||
umask 077
|
||||
export_dir="$(mktemp -d)" &&
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--output "$export_dir/audit-export.json" \
|
||||
http://127.0.0.1:7655/api/audit/export &&
|
||||
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
|
||||
```
|
||||
|
||||
The export includes all events matching the current filter criteria.
|
||||
This creates a new private directory and prints the file's location only on
|
||||
success. It does not reuse filters selected in the UI. Treat the export as
|
||||
sensitive data; keep it outside shared repositories and issue attachments.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
|
||||
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
+84
-44
@@ -51,9 +51,30 @@ Each member has a role within an organization:
|
||||
|
||||
Organizations can share specific resources with other organizations:
|
||||
|
||||
- Share a VM, container, host, or storage resource with another org.
|
||||
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
|
||||
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
|
||||
- The receiving org sees shared resources alongside their own, with a share badge.
|
||||
- The receiving org must accept the share before it sees the resource alongside its own.
|
||||
|
||||
## Before Using the API
|
||||
|
||||
Create organizations and manage members and shares in the signed-in Pulse UI.
|
||||
These changes require **session-based user authentication** and the relevant
|
||||
organization role; API tokens are rejected with `403 session_required`, even
|
||||
when they have `settings:write`. The UI handles the session and CSRF protection.
|
||||
Do not extract a session cookie or CSRF token into a shell command.
|
||||
|
||||
The read-only curl examples use an org-bound token with `settings:read` and
|
||||
the private header file from [API authentication](API.md#-authentication).
|
||||
Keep the token out of command lines, URLs and reports. A token's organization
|
||||
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
|
||||
not grant it access to another organization.
|
||||
|
||||
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
|
||||
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
|
||||
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
|
||||
keep certificate verification enabled. Replace the example organization ID
|
||||
`production-datacenter` with your own. Run requests separately, and share only
|
||||
the relevant redacted error, not whole member or infrastructure responses.
|
||||
|
||||
## Managing Organizations
|
||||
|
||||
@@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations:
|
||||
|
||||
**UI:** Settings → Organization → Create Organization
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7655/api/orgs \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
|
||||
**API contract:** `POST /api/orgs`, session authentication only:
|
||||
```json
|
||||
{"id": "production-datacenter", "displayName": "Production Datacenter"}
|
||||
```
|
||||
|
||||
The creator becomes the owner. `id` is a stable ID of 1–64 characters using
|
||||
letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a
|
||||
simple lowercase/hyphen ID such as the example. `displayName` is the name
|
||||
shown in Pulse. `name` and `description` are not the creation fields.
|
||||
|
||||
### Switching Organizations
|
||||
|
||||
Use the **Org Switcher** dropdown in the header. When you switch:
|
||||
@@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch:
|
||||
|
||||
**UI:** Settings → Organization → Access
|
||||
|
||||
**API:**
|
||||
**Read-only API:**
|
||||
```bash
|
||||
# List members
|
||||
curl http://localhost:7655/api/orgs/{orgId}/members \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Add a member
|
||||
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId": "user-id", "role": "editor"}'
|
||||
|
||||
# Update role
|
||||
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"role": "admin"}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/orgs/production-datacenter/members
|
||||
```
|
||||
|
||||
**Invite or update a member:** use the Access panel as an owner or admin.
|
||||
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
|
||||
```json
|
||||
{"userId": "user-id", "role": "editor"}
|
||||
```
|
||||
|
||||
For a new member, the response is `202` with a pending invitation; the user
|
||||
must accept it in Pulse before gaining access. An existing member's role is
|
||||
updated by posting their `userId` and new role to the same endpoint, not by
|
||||
PATCHing a member URL:
|
||||
```json
|
||||
{"userId": "user-id", "role": "admin"}
|
||||
```
|
||||
|
||||
Only the current owner can transfer ownership, and only to an existing member
|
||||
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
|
||||
member update. Default-organization members cannot be managed here.
|
||||
|
||||
### Sharing Resources
|
||||
|
||||
**UI:** Settings → Organization → Sharing
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
# Create a share
|
||||
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"targetOrgId": "other-org-id",
|
||||
"resourceType": "host",
|
||||
"resourceId": "resource-id",
|
||||
"role": "viewer"
|
||||
}'
|
||||
Create the share as an owner or admin of the source organization. An owner or
|
||||
admin of the target organization then accepts it in **Sharing → Incoming**.
|
||||
Until acceptance, the share is pending and does not grant access.
|
||||
|
||||
# View incoming shares
|
||||
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
|
||||
```json
|
||||
{
|
||||
"targetOrgId": "other-org-id",
|
||||
"resourceType": "vm",
|
||||
"resourceId": "vm:101",
|
||||
"accessRole": "viewer"
|
||||
}
|
||||
```
|
||||
|
||||
Use `accessRole`, not `role`, and the resource type and ID returned by Pulse,
|
||||
not a display name. Supported types include `vm`, `system-container`, `agent`,
|
||||
`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and
|
||||
`container` are not supported. Changing a share's access role makes it pending
|
||||
again, so the target must accept the new grant.
|
||||
|
||||
**Read-only incoming shares:**
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
|
||||
```
|
||||
|
||||
## Monitoring Multiple Internal Estates
|
||||
@@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
|
||||
| `GET` | `/api/orgs` | List organizations the current user can access |
|
||||
| `POST` | `/api/orgs` | Create a new organization |
|
||||
| `GET` | `/api/orgs/{id}` | Get organization details |
|
||||
| `PATCH` | `/api/orgs/{id}` | Update organization |
|
||||
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}` | Delete organization |
|
||||
| `GET` | `/api/orgs/{id}/members` | List members |
|
||||
| `POST` | `/api/orgs/{id}/members` | Add a member |
|
||||
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
|
||||
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
|
||||
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
|
||||
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
|
||||
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
|
||||
| `POST` | `/api/orgs/{id}/shares` | Create a share |
|
||||
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
|
||||
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
|
||||
|
||||
### Tenant Context
|
||||
@@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active
|
||||
2. Session cookie (browser)
|
||||
3. Falls back to the `default` organization
|
||||
|
||||
Neither the active context nor a token scope overrides organization membership
|
||||
or a token's organization binding.
|
||||
|
||||
## Storage
|
||||
|
||||
- The **default** org uses the root data directory (backward compatible).
|
||||
@@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
|
||||
|
||||
### Shared resources not appearing
|
||||
|
||||
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
|
||||
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
|
||||
2. Confirm the share role grants sufficient access.
|
||||
3. Check that the source org's resources are online.
|
||||
|
||||
|
||||
+149
-70
@@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways:
|
||||
|
||||
### 1. Direct PBS Connection (Recommended)
|
||||
|
||||
Connect directly to your PBS server for full monitoring capabilities:
|
||||
Connect to the PBS API with a dedicated read-only token. An agent is not
|
||||
required for these API-backed readings:
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Deduplication factor and storage efficiency stats
|
||||
@@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
|
||||
- ❌ Can be slow for encrypted PBS storage
|
||||
- ❌ Limited metadata per backup
|
||||
|
||||
**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
|
||||
**Recommendation:** Start with a direct API connection when you need PBS
|
||||
datastore, job or server status beyond the PVE passthrough. Add a host agent
|
||||
only for extra local telemetry such as SMART and temperatures; see
|
||||
[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices).
|
||||
|
||||
---
|
||||
|
||||
## Setting Up Direct PBS Connection
|
||||
|
||||
### Method 1: Unified Agent Install (Recommended for Bare Metal)
|
||||
### Method 1: API-Only Connection (Recommended)
|
||||
|
||||
Install the unified agent directly on your PBS server for automatic setup:
|
||||
Use a dedicated PBS monitoring user and token, not a root password. On a new
|
||||
setup, run these commands in an administrator shell **on the PBS server**:
|
||||
|
||||
```bash
|
||||
# Run on your PBS server
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
sudo bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox --proxmox-type pbs
|
||||
```
|
||||
|
||||
The agent will:
|
||||
1. Detect it's running on a PBS server
|
||||
2. Create a `pulse-monitor@pbs` user with read-only access
|
||||
3. Generate an API token
|
||||
4. Register the PBS node with Pulse automatically
|
||||
|
||||
### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐
|
||||
|
||||
Use this when you can run a command on the PBS host but do not want to install the agent.
|
||||
|
||||
From Pulse's Settings page:
|
||||
1. Go to **Settings → Infrastructure**.
|
||||
2. Click **Add infrastructure**.
|
||||
3. Choose **Proxmox Backup Server**.
|
||||
4. Use the API-only setup path and enter your PBS server's URL.
|
||||
5. Click copy to get the setup command.
|
||||
6. Run the command on your PBS server.
|
||||
|
||||
Example (what the UI generates):
|
||||
```bash
|
||||
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
|
||||
```
|
||||
|
||||
Pulse generates that full command for you from **Settings → Infrastructure**, including
|
||||
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
|
||||
generates a scoped API token, and registers the server with Pulse.
|
||||
|
||||
> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality.
|
||||
|
||||
> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding.
|
||||
|
||||
### Method 3: Manual Token Creation
|
||||
|
||||
If you prefer manual setup:
|
||||
|
||||
```bash
|
||||
# SSH into your PBS server
|
||||
|
||||
# 1. Create a dedicated monitoring user
|
||||
proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring"
|
||||
|
||||
# 2. Grant read-only access (Audit role)
|
||||
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
||||
|
||||
# 3. Generate an API token (save the output!)
|
||||
proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
|
||||
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
||||
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'
|
||||
```
|
||||
|
||||
Copy the token value and enter it in Pulse:
|
||||
- **Token ID:** `pulse-monitor@pbs!pulse-token`
|
||||
- **Token Value:** The UUID shown after running the command
|
||||
PBS tokens have their own permissions, limited by their owning user's
|
||||
permissions. Grant `Audit` to **both the user and the token**. If either already
|
||||
exists, inspect and reuse it instead of deleting it or rerunning token creation.
|
||||
Do not disable privilege separation or grant `Admin` to work around a missing
|
||||
permission.
|
||||
|
||||
The token secret is shown once. Save it privately and enter it only in Pulse's
|
||||
**Token Value** field; do not paste it into a shell command, URL, screenshot or
|
||||
issue report. Keep any terminal recording containing that output private.
|
||||
|
||||
In Pulse:
|
||||
1. Open **Settings → Infrastructure → Add infrastructure** and choose
|
||||
**Proxmox Backup Server**.
|
||||
2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`.
|
||||
3. Select **API Token** and **Manual Token Setup**.
|
||||
4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in
|
||||
**Token Value**.
|
||||
5. Keep certificate verification enabled. For a self-signed certificate, use
|
||||
an independently verified **SSL Fingerprint**; see the TLS guidance below.
|
||||
6. Test and save the connection. Check that the expected datastores and backups
|
||||
are visible, not just that the connection test succeeds.
|
||||
|
||||
### Method 2: Optional Host Agent
|
||||
|
||||
Use this only when you also need host-local telemetry. The Linux agent normally
|
||||
runs as root; it is not required to fix API authentication or missing backup
|
||||
permissions. Review [Agent Security](AGENT_SECURITY.md) first.
|
||||
|
||||
Create a separate Pulse token in **API Access**, using the **Agent host** preset
|
||||
for reporting, configuration reads and agent management. This is a **Pulse**
|
||||
token, not the **PBS** token used above. Do not enable command execution just
|
||||
for monitoring.
|
||||
|
||||
On the PBS host, enter an administrator root shell before preparing the private
|
||||
file below. In the editor, save only the Pulse agent token, with no header or
|
||||
quotes; never put the secret in a command argument:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
|
||||
vi "$HOME/.config/pulse/pbs-agent-token"
|
||||
```
|
||||
|
||||
Download the agent installer from **your Pulse server's HTTPS address**, then
|
||||
inspect the saved script before running it. Replace the example Pulse URL in
|
||||
both commands. Stop if the download fails; run the next command only after a
|
||||
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
|
||||
the Pulse server, not the agent.
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/pbs-agent-token" \
|
||||
--enable-proxmox --proxmox-type pbs --enable-docker=false
|
||||
```
|
||||
|
||||
Do not bypass certificate checks to fetch or run the installer. Use a trusted
|
||||
Pulse certificate or a CA file you verified separately. See
|
||||
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
|
||||
After installation, check a fresh agent report and the host's
|
||||
`pulse-agent --version`; installation or hardware capacity alone does not prove
|
||||
that every CPU, memory or History reading is available. Keep the token file
|
||||
private and remove the bootstrap copy when no longer needed; do not remove the
|
||||
installed agent's runtime credential.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil
|
||||
|
||||
### "Connection Failed" Error
|
||||
|
||||
1. **Check URL:** Ensure the PBS URL is correct (default port is 8007)
|
||||
- Format: `https://pbs.example.com:8007`
|
||||
Check from the Pulse host or container's network, if possible. A request from
|
||||
another machine does not prove that Pulse can reach PBS.
|
||||
|
||||
2. **Verify token:** Test authentication:
|
||||
```bash
|
||||
curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \
|
||||
https://your-pbs:8007/api2/json/version
|
||||
```
|
||||
- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing
|
||||
and the firewall before changing credentials.
|
||||
- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse
|
||||
runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value
|
||||
through the PBS console or another already-trusted administrative channel.
|
||||
Do not accept a fingerprint solely from the failed connection. For example,
|
||||
on the PBS server you can inspect its public certificate (not its private key):
|
||||
|
||||
3. **Network access:** Ensure Pulse can reach PBS on port 8007
|
||||
```bash
|
||||
openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256
|
||||
```
|
||||
|
||||
4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings
|
||||
- **Authentication/permissions:** test a datastore request, not just `/version`.
|
||||
Prepare a private header file on the machine running curl:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-header"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-header"
|
||||
vi "$HOME/.config/pulse/pbs-header"
|
||||
```
|
||||
|
||||
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
|
||||
for the PBS token being tested:
|
||||
|
||||
```text
|
||||
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
|
||||
```
|
||||
|
||||
Then use curl 7.76 or later, with your PBS hostname:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
|
||||
--header "@$HOME/.config/pulse/pbs-header" \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore
|
||||
```
|
||||
|
||||
For curl with a private CA or self-signed certificate, add
|
||||
`--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained
|
||||
and verified through a trusted channel. Its hostname must still match. Do not
|
||||
use `--insecure` or `-k`.
|
||||
|
||||
`401` means authentication was rejected; `403` means the requested access was
|
||||
refused. Both return a non-zero curl exit while retaining the error body.
|
||||
Inspect the existing token and both `Audit` grants before replacing anything.
|
||||
A `200` response listing the expected datastore establishes access to that
|
||||
list, not successful collection of every backup, job or History graph. An
|
||||
empty list is not proof that all permissions are correct.
|
||||
|
||||
Keep the header file outside shared repositories and diagnostics. Do not share
|
||||
verbose/trace curl output, full infrastructure responses, token values or
|
||||
private keys. If help is needed, provide only the HTTP status, relevant redacted
|
||||
error and which expected reading is missing. Do not clear History or recreate a
|
||||
working connection to make missing data look resolved.
|
||||
|
||||
### Slow Backup Loading
|
||||
|
||||
@@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE:
|
||||
|
||||
### Duplicate Backups
|
||||
|
||||
If you see the same backup twice:
|
||||
- This shouldn't happen—Pulse deduplicates by VMID and timestamp
|
||||
- If it does occur, the direct PBS version takes priority
|
||||
- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')`
|
||||
Check the source, datastore, namespace, guest type/ID and backup time on each
|
||||
entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone
|
||||
are not proof of a duplicate. Keep both records while checking their origin.
|
||||
If the same backup remains listed twice, report those redacted distinctions and
|
||||
whether each entry came from direct PBS or PVE passthrough. Do not delete backups
|
||||
or change retention to hide a display problem.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator:
|
||||
- **"PBS"** badge alone = Direct PBS connection (full data)
|
||||
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)
|
||||
|
||||
Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities.
|
||||
When the same backup is reconciled across both sources, Pulse prefers the
|
||||
direct PBS observation. Check the actual source and expected readings after
|
||||
adding the connection; the presence of a badge alone is not collection proof.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+61
-30
@@ -46,42 +46,65 @@ user merely because no local administrator is configured.
|
||||
|
||||
## Managing Roles
|
||||
|
||||
### Before Using the API
|
||||
|
||||
Prefer **Settings → Security → Access Control** for one-off administration.
|
||||
The API examples require the licensed `rbac` capability and permission to
|
||||
administer users. With RBAC enabled, role and user administration requires a
|
||||
full-access (`*`) API token bound to an authorised administrator; a monitoring
|
||||
token is not enough. Do not widen an agent's token for this job.
|
||||
|
||||
If you already use an administration token, prepare its private header file
|
||||
as described in [API authentication](API.md#-authentication). The examples read
|
||||
`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into
|
||||
a command. Keep this file outside shared repositories and diagnostics.
|
||||
|
||||
The loopback URLs work on the Pulse host. For remote use, substitute your
|
||||
Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or
|
||||
later; `--disable` must remain first to ignore local trace/verbose defaults,
|
||||
and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each
|
||||
change separately and check its response before continuing.
|
||||
|
||||
### Creating a Role
|
||||
|
||||
**UI:** Settings → Security → Access Control → Create Role
|
||||
|
||||
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
|
||||
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7655/api/admin/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "operator",
|
||||
"name": "Operator",
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON'
|
||||
{
|
||||
"id": "alert-manager",
|
||||
"name": "Alert Manager",
|
||||
"description": "Can view and manage alerts",
|
||||
"permissions": [
|
||||
{"action": "read", "resource": "alerts"},
|
||||
{"action": "write", "resource": "alerts"},
|
||||
{"action": "read", "resource": "nodes"}
|
||||
]
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Listing Roles
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/admin/roles \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/admin/roles
|
||||
```
|
||||
|
||||
### Updating a Role
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/roles/operator \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Operator",
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON'
|
||||
{
|
||||
"name": "Alert Manager",
|
||||
"description": "Updated description",
|
||||
"permissions": [
|
||||
{"action": "read", "resource": "alerts"},
|
||||
@@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
|
||||
{"action": "read", "resource": "nodes"},
|
||||
{"action": "read", "resource": "ai"}
|
||||
]
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Deleting a Role
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
|
||||
```
|
||||
|
||||
---
|
||||
@@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
|
||||
### Listing Users and Their Roles
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/admin/users \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/admin/users
|
||||
```
|
||||
|
||||
SSO users are displayed using the latest configured username claim and email
|
||||
@@ -118,20 +142,27 @@ principal used for authorization.
|
||||
|
||||
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
|
||||
|
||||
Use the stable `username` returned by the user list, not the display name or
|
||||
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
|
||||
SSO principal). `jane` below is an example local username. Create the custom
|
||||
role before assigning it; do not run the deletion example first.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"roleIds": ["operator", "viewer"]}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
|
||||
{"roleIds": ["alert-manager", "viewer"]}
|
||||
JSON
|
||||
```
|
||||
|
||||
To remove all custom roles from a user, send an empty list:
|
||||
To clear a user's entire role assignment, including built-in roles, send an empty list:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"roleIds": []}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
|
||||
{"roleIds": []}
|
||||
JSON
|
||||
```
|
||||
|
||||
Note: Users cannot modify their own role assignments (self-escalation prevention).
|
||||
@@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
|
||||
### Removing User Access
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7655/api/admin/users/jane \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
|
||||
```
|
||||
|
||||
This removes the Pulse identity and all role assignments and revokes its active
|
||||
|
||||
@@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the
|
||||
|
||||
## Recommended: Pulse Agent (Proxmox)
|
||||
|
||||
The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed.
|
||||
The unified agent runs on each Proxmox host and reports temperatures locally
|
||||
with no SSH keys needed. First complete the
|
||||
[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas)
|
||||
on that host: protect the Pulse agent token, download the installer from your
|
||||
Pulse server over verified HTTPS, and inspect it. Then use the same Pulse
|
||||
address and add the Proxmox collector flag:
|
||||
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox
|
||||
```
|
||||
|
||||
The Pulse agent token is not a Proxmox API token. Keep the secret out of
|
||||
command arguments and leave command execution disabled for monitoring.
|
||||
|
||||
Notes:
|
||||
- Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`).
|
||||
- Temperatures appear automatically once the agent reports.
|
||||
@@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
|
||||
|
||||
## Legacy Cleanup (If Upgrading)
|
||||
|
||||
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
|
||||
If you still have the old sensor proxy installed from prior releases, remove it
|
||||
from each **Proxmox host** (not the Pulse container) with the supported cleanup
|
||||
helper. In that host's administrative shell, download it to a private directory:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
|
||||
sudo bash -s -- --uninstall --purge --local-only
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
|
||||
--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh
|
||||
```
|
||||
|
||||
Stop if the download fails and inspect the saved script before running it.
|
||||
Do not pipe a web response into a privileged shell:
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
--uninstall --purge --local-only
|
||||
```
|
||||
|
||||
`--local-only` avoids cluster SSH entirely; run the command once on every
|
||||
@@ -122,8 +145,8 @@ remote portion fail after local cleanup completes.
|
||||
If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
|
||||
sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only
|
||||
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
--uninstall --purge --remove-proxmox-access --local-only
|
||||
```
|
||||
|
||||
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.
|
||||
|
||||
+147
-62
@@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Generate an installation command in the UI:
|
||||
Choose the host profile and create a monitoring token in the UI:
|
||||
**Settings → Infrastructure → Install on a host**
|
||||
|
||||
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
|
||||
|
||||
The generated command is not tied to a single machine. For a Proxmox VE
|
||||
cluster, one API connection already provides cluster-wide inventory; the agent
|
||||
is per host, so run the same generated command on each cluster node where you
|
||||
want agent-provided telemetry (temperatures, SMART, host identity). Each agent
|
||||
registers itself and attaches to its own cluster member.
|
||||
Keep the token out of shell commands, history, URLs and screenshots. Use the
|
||||
private-file installation steps below; the command arguments contain only the
|
||||
file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring
|
||||
does not require command execution: leave that option off unless you intend
|
||||
to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root
|
||||
host agent instead of an API connection or the opt-in Linux safe profile.
|
||||
|
||||
The same generated command is also the supported v5-to-v6 agent upgrade path.
|
||||
For a Proxmox VE cluster, one API connection already provides cluster-wide
|
||||
inventory; the agent is per host. Repeat the private-file installation on each
|
||||
cluster node where you want agent-provided telemetry (temperatures, SMART, host
|
||||
identity), using the intended profile. Each agent registers itself and attaches
|
||||
to its own cluster member.
|
||||
|
||||
The same installer is also the supported v5-to-v6 agent upgrade path.
|
||||
Run it on the host that already has the v5 `pulse-agent` service to replace the
|
||||
binary and service configuration in place; do not uninstall the old service
|
||||
first unless you are intentionally removing that host from Pulse.
|
||||
@@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL
|
||||
so replacing the Pulse host does not require an agent migration.
|
||||
|
||||
After importing the configuration on a Pulse server with a different address,
|
||||
retarget each existing standard Linux agent from that agent machine:
|
||||
retarget each existing standard Linux agent from that agent machine. Download
|
||||
and inspect the installer from the **new** Pulse address using the HTTPS
|
||||
preparation below; retargeting reuses the saved credential, so do not create
|
||||
another token just for this operation:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pulse.example.com:7655/install.sh | \
|
||||
sudo bash -s -- --retarget --url https://pulse.example.com:7655
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--retarget --url https://pulse.example.com
|
||||
```
|
||||
|
||||
The retarget operation recovers the existing token, agent ID, enabled
|
||||
collectors, and other service options. It does not carry the old endpoint's
|
||||
TLS bypass, custom CA, or certificate fingerprint to the new address. Supply
|
||||
`--cacert`, `--server-fingerprint`, or (only on a trusted network)
|
||||
`--insecure` explicitly when the new endpoint requires it. The script must
|
||||
come from the new server so it supports the retarget operation. A newly
|
||||
generated full installation command from **Settings → Infrastructure → Install
|
||||
on a host** remains the fallback.
|
||||
`--cacert` or an independently verified `--server-fingerprint` explicitly
|
||||
when the new agent endpoint requires it. The installer download itself must
|
||||
use a trusted certificate or a separately verified CA file; an agent
|
||||
fingerprint option does not verify that earlier download. The script must
|
||||
come from the new server so it supports retargeting. If retargeting is not
|
||||
supported on that host, use the private-file installation below with the
|
||||
intended profile, rather than uninstalling the existing service first.
|
||||
|
||||
On Windows, run the full generated PowerShell installation command from the
|
||||
new Pulse server as Administrator, including the desired collector options.
|
||||
On Windows, use the PowerShell private-file installation below from the new
|
||||
Pulse server as Administrator, including the desired collector options.
|
||||
Do not expect the configuration import itself to make agent-only machines
|
||||
appear at the new address.
|
||||
|
||||
@@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with
|
||||
This is the agent installer served by your Pulse server. It is separate from the
|
||||
top-level GitHub `install.sh`, which installs or updates the Pulse server itself.
|
||||
|
||||
### Linux (systemd)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
### Private-file installation: Linux, macOS and NAS
|
||||
|
||||
### macOS
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
Use an administrative shell **on the host being monitored**, not the Pulse
|
||||
server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS
|
||||
use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API
|
||||
connection is usually enough for TrueNAS inventory and usage; an agent is
|
||||
optional for host-local data.
|
||||
|
||||
1. In that shell, protect the file before opening the editor. Save only the
|
||||
Pulse agent token, with no quotes or header. Use your own private directory
|
||||
and regular files, not shared paths or symlinks:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/agent-token"
|
||||
vi "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address
|
||||
in both the download and installation commands. Download to a file:
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
|
||||
--output "$HOME/.config/pulse/agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
```
|
||||
|
||||
**Stop if the download fails.** Inspect the saved script before executing
|
||||
it. Do not bypass certificate verification or pipe an unchecked response
|
||||
into a privileged shell. For a private CA, add curl's `--cacert` with the
|
||||
separately verified CA file, and supply the installer's `--cacert` option
|
||||
for the agent connection as well. Do not substitute GitHub's top-level
|
||||
`install.sh`: that installs the Pulse server, not the agent.
|
||||
|
||||
3. Install with the private token file, adding a profile from
|
||||
[Installation Options](#installation-options) when needed:
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
Keep the bootstrap token file private and remove that copy when no longer
|
||||
needed; do not delete the installed agent's runtime credential. Check a fresh
|
||||
report in Pulse and `pulse-agent --version` on the host. A started service or
|
||||
hardware capacity alone does not prove that every requested metric is present.
|
||||
|
||||
### Windows (PowerShell, run as Administrator)
|
||||
|
||||
Use a **new** setup directory under your own Windows profile; if it already
|
||||
exists, choose another name. The following preparation grants access only to
|
||||
your account and SYSTEM. Stop if directory creation or the ACL command fails:
|
||||
|
||||
```powershell
|
||||
irm http://<pulse-ip>:7655/install.ps1 | iex
|
||||
$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup'
|
||||
New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null
|
||||
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' }
|
||||
$tokenFile = Join-Path $setupDir 'agent-token.txt'
|
||||
New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null
|
||||
Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait
|
||||
```
|
||||
|
||||
With environment variables:
|
||||
In Notepad, save only the Pulse agent token, with no quotes or header. Never
|
||||
paste it into a PowerShell command or assign a literal secret to an environment
|
||||
variable. Replace the example HTTPS address in both commands below. Download
|
||||
and inspect the script first; do not use `Invoke-Expression` on a web response:
|
||||
|
||||
```powershell
|
||||
$env:PULSE_URL="http://<pulse-ip>:7655"
|
||||
$env:PULSE_TOKEN="<api-token>"
|
||||
irm http://<pulse-ip>:7655/install.ps1 | iex
|
||||
$installerFile = Join-Path $setupDir 'install.ps1'
|
||||
Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop
|
||||
```
|
||||
|
||||
### Synology NAS
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
Only after a successful download and inspection, run the saved script. Clear
|
||||
an old `PULSE_TOKEN` environment value so it cannot override the file:
|
||||
|
||||
```powershell
|
||||
Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue
|
||||
& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile
|
||||
```
|
||||
|
||||
### TrueNAS SCALE/CORE
|
||||
TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE).
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
Keep the setup directory private and remove the bootstrap copy when no longer
|
||||
needed, not the installed service's token. Verify a fresh report and the
|
||||
installed agent version; the remaining platform trust trade-offs are in
|
||||
[Agent Security](AGENT_SECURITY.md).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing.
|
||||
|
||||
## Installation Options
|
||||
|
||||
These commands use the protected token file and successfully downloaded,
|
||||
inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas).
|
||||
Use the same HTTPS address in preparation and installation. The flags below
|
||||
choose collectors; they do not grant command execution.
|
||||
|
||||
### Simple Install (host + Docker auto-detect)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token>
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
### Proxmox VE Node (explicit profile)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pve
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve
|
||||
```
|
||||
|
||||
### Proxmox Backup Server Node (explicit profile)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pbs
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs
|
||||
```
|
||||
|
||||
### Force Enable Docker (if auto-detection fails)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-docker
|
||||
```
|
||||
|
||||
### Disable Docker (even if detected)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-docker=false
|
||||
```
|
||||
|
||||
### Host + Kubernetes Monitoring
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes
|
||||
```
|
||||
|
||||
### Docker Monitoring Only
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker
|
||||
```
|
||||
|
||||
### Exclude Specific Disks from Monitoring
|
||||
@@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop.
|
||||
|
||||
To disable auto-updates:
|
||||
```bash
|
||||
# During installation
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
|
||||
# After private-file preparation and successful installer inspection
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --disable-auto-update
|
||||
|
||||
# Or set environment variable
|
||||
PULSE_DISABLE_AUTO_UPDATE=true
|
||||
@@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys
|
||||
|
||||
## Uninstall
|
||||
|
||||
Download and inspect the agent installer using the HTTPS preparation above
|
||||
before running this on the agent host. Uninstallation needs no new token:
|
||||
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | bash -s -- --uninstall
|
||||
bash "$HOME/.config/pulse/agent-install.sh" --uninstall
|
||||
```
|
||||
|
||||
This removes:
|
||||
@@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged
|
||||
and installed copy must fit at once.
|
||||
|
||||
If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a
|
||||
directory on a data volume and re-run the installer:
|
||||
directory on a data volume and re-run the already downloaded and inspected
|
||||
agent installer from the private-file preparation above:
|
||||
|
||||
```bash
|
||||
TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token <token>
|
||||
TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
(`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root.
|
||||
|
||||
@@ -242,16 +242,16 @@ user language should update the control plane.
|
||||
## Current State
|
||||
|
||||
1. v6 is the current active release profile.
|
||||
2. `v6-product-lane-expansion` is the current active engineering target.
|
||||
Pulse v6 GA and the initial 6.0.x patch line have shipped; active
|
||||
development and stable release preparation now run on `main`.
|
||||
2. `v6-release-reliability` is the current active engineering target.
|
||||
Pulse v6 has shipped. Active development runs on `main`; release preparation
|
||||
uses the exact source line resolved by `control_plane.json`.
|
||||
3. `v6-ga-promotion` is complete. Its release records remain historical
|
||||
evidence and must not keep pre-GA branch, checkout, or readiness posture
|
||||
active in current lane state.
|
||||
4. Candidate lanes and coverage gaps now route post-GA product expansion.
|
||||
Release-blocking correctness work may still override that default queue
|
||||
when a shipped customer contract can drift across billing, entitlements,
|
||||
runtime behavior, or public copy.
|
||||
4. Reported defects, dependable delivery and release qualification take
|
||||
priority over new product expansion. Candidate lanes and coverage gaps
|
||||
support that work; billing, entitlements, runtime behavior and public copy
|
||||
remain customer contracts that must not drift.
|
||||
5. `v6-rc-stabilization` is completed after the shipped RCs established the
|
||||
current monitored-first floor and the active objective moved to stable
|
||||
promotion.
|
||||
@@ -259,8 +259,10 @@ user language should update the control plane.
|
||||
7. The existing v6 control surfaces are still live, but they now sit underneath
|
||||
an evergreen Pulse control plane rather than pretending to be the whole
|
||||
long-term system.
|
||||
8. Both prerelease and stable v6 promotions resolve to `main` via
|
||||
`control_plane.json`.
|
||||
8. The v6 profile defaults to `main`; explicit version overrides in
|
||||
`control_plane.json` bind existing releases to `release/v6.4` and
|
||||
`release/v6.5`. A new checkpoint's mapping is verified during governed
|
||||
preparation, not inferred from the profile default or this page.
|
||||
9. Legacy maintenance releases that still feed governed automation outside the
|
||||
active v6 line must also resolve through `control_plane.json`.
|
||||
Right now the remaining `5.1.x` stable maintenance line resolves to `main`
|
||||
|
||||
@@ -1021,9 +1021,20 @@ Companion drill:
|
||||
`.github/scripts/check-demo-reachability.sh`
|
||||
`scripts/trigger-stable-patch.sh`
|
||||
- Automated proof:
|
||||
`go test ./scripts/installtests -run 'TestStablePatchReleaseNotes' -count=1`
|
||||
`cd scripts/release_control && python3 -m unittest render_release_body_test`
|
||||
`cd scripts/release_control && python3 -m unittest secure_runtime_rootful_attestation_v1_test release_note_visuals_test documentation_currentness_test`
|
||||
- The rootful source-closure test must cover the current filesystem probe and
|
||||
shared filesystem evidence packages as well as the compiled harness. Its
|
||||
complete missing-package list is a failed boundary check, not qualification.
|
||||
`cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test`
|
||||
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
|
||||
- Manual scenario:
|
||||
- Compare the exact selected release notes with the install-metadata verdict.
|
||||
Authored grouped notes must retain version identity, unsigned-Windows and
|
||||
publisher warning, mobile compatibility and the exact stable rollback. A
|
||||
wording mismatch is not a product-runtime diagnosis. Do not mutate a shipped
|
||||
release or skip functional checks to obtain a passing watchdog.
|
||||
1. Push the exact candidate commit to the governed stable branch.
|
||||
2. For a no-public-release rehearsal, dispatch
|
||||
`./scripts/trigger-stable-patch.sh --dry-run <version>`.
|
||||
|
||||
@@ -3452,6 +3452,11 @@ Agent` secondary handoff against the live setup wizard instead of relying
|
||||
|
||||
## Current State
|
||||
|
||||
### Credential-safe Proxmox bootstrap (1 October 2026)
|
||||
|
||||
Proxmox initial bootstrap keeps credentials out of copied shell source. The current PVE/PBS agent command fetches a complete installer, runs its credential-free preflight, then privately prompts inside the root/sudo Bash child. Its 0700 directory and 0600 token file are owned by that child, passed only by path and removed on exit or handled signals. The UI reveals credentials separately, clears cached setup material on close and discards late issuance after close/reset. No exec scope is added. Existing fleet reporting is unchanged; newly enrolling an older agent with a newer server requires the current installer. New agents and the root installer still validate coherent old-server artifacts during rolling upgrades, but never execute their command strings.
|
||||
|
||||
|
||||
### Update progress stream delivery
|
||||
|
||||
Server update progress delivery is owned by internal/updates and its API
|
||||
|
||||
@@ -4579,6 +4579,11 @@ auto-register mutation boundary.
|
||||
|
||||
## Current State
|
||||
|
||||
### Credential-safe Proxmox bootstrap (1 October 2026)
|
||||
|
||||
The current Proxmox setup artifact supersedes the former token-bearing command/download contract: `command`, `commandWithEnv` and `commandWithoutEnv` are identical credential-free, single-line transports. `downloadURL` equals the tokenless `url`; downloads embed no setup token. `setupToken` (or agent-install `token`) is returned separately through the authenticated issuance response for an explicit reveal, never interpolated by the consumer. Settings uses the existing token-reveal dialog and tells users to run the command before pasting at its silent terminal prompt. Setup cache remains bound to endpoint/mode/live five-minute expiry and is discarded on modal close. The rendered scripts accept a bounded private `PULSE_SETUP_TOKEN_FILE` before mutation, unexport the secret, and put registration credentials in stdin, not process arguments. Legacy explicit query-token downloads remain accepted for compatibility, but no current artifact generates those URLs. Failed attempted registration returns nonzero and never echoes the response body. Runtime and root-installer consumers validate either the complete new artifact or the coherent old-server artifact; mixed transports fail closed.
|
||||
|
||||
|
||||
### Update progress stream delivery
|
||||
|
||||
`GET /api/updates/stream` answers `text/event-stream` with
|
||||
|
||||
@@ -1925,9 +1925,23 @@ artifact-selection behaviour.
|
||||
duplicate appended `Installation` / `Promotion Metadata` sections verbatim.
|
||||
From the release after `v6.4.0-rc.1` onward, that renderer also owns a
|
||||
customer-facing communication contract. Public notes lead with one short
|
||||
outcome paragraph, use a scannable `What's improved` section with no more
|
||||
than six concrete items, keep fixes symptom-led, and reserve `Before you
|
||||
outcome paragraph, use scannable symptom-led changes, and reserve `Before you
|
||||
upgrade` or `Known issues` for information users must act on or understand.
|
||||
From v6.4.6 onward, the authoring tools and renderer accept a short plain-text
|
||||
`Highlights` list and grouped customer sections (alerts, storage, PBS, NAS,
|
||||
updates, Pro, service health, security or other improvements). A narrow patch
|
||||
may retain `What's improved`. Groups must be non-empty and not duplicate the
|
||||
same change, bullets remain bounded, and internal status headings stay out.
|
||||
The install-metadata checks bind the current version in the notes title and
|
||||
retain Windows signing/warning, companion compatibility and exact stable
|
||||
rollback disclosures. Stable maturity and promotion lineage remain bound by
|
||||
the changelog and release metadata, not a mandatory public boilerplate
|
||||
sentence. Historical published notes are not edited or re-rendered to fit a
|
||||
later authoring template.
|
||||
The rootful qualification source manifest retains the complete compiled
|
||||
install-test package, including the release-notes helper and its exact
|
||||
authored-copy fixture. Accepting grouped notes must not silently narrow
|
||||
that attested source boundary.
|
||||
Qualification counts, readiness assertions, release gates, workflow
|
||||
narration, artifact identity, and promotion metadata stay in governed
|
||||
workflow summaries and evidence records rather than the public changelog.
|
||||
@@ -2556,6 +2570,11 @@ artifact-selection behaviour.
|
||||
|
||||
## Current State
|
||||
|
||||
### Credential-safe Proxmox bootstrap (1 October 2026)
|
||||
|
||||
Current PVE auto-registration metadata accepts the credential-free setup artifact: all command aliases use a private-file handoff and `downloadURL` equals the tokenless script URL. This replaces earlier requirements to embed setup tokens in commands/URLs. The older coherent server artifact is accepted read-only during upgrades, never executed. Host, type, canonical filename/URL, masked hint and live expiry remain required. Root-installer JSON parsing and registration pass secrets through descriptor/stdin input rather than Python/curl argv, and the setup response is no longer persisted as a plaintext /tmp diagnostic. No install source, API scope, trust exception, release selector or success condition is widened.
|
||||
|
||||
|
||||
### Update progress stream delivery
|
||||
|
||||
`GET /api/updates/stream` is the in-app updater's progress feed and must never
|
||||
@@ -6201,3 +6220,13 @@ rewriting the author's instructions or selecting another rollback version.
|
||||
distant-scope rejection, and valid generated and inline ownership conditions.
|
||||
This changes publication validation, not installer behaviour, immutable release
|
||||
contents or proof of an installed upgrade/rollback.
|
||||
|
||||
## Rootful qualification source closure (1 October 2026)
|
||||
|
||||
The rootful source manifest includes `internal/filesystemprobe` and
|
||||
`pkg/agents/filesystem`: both are compiled dependencies of the current collector
|
||||
and qualification harness. Omitting them would leave the attested packet
|
||||
unbound to filesystem-observation code that it actually executes. The closure
|
||||
test enumerates all repository-local dependencies of the install-test binary,
|
||||
collector and helper and reports every missing package together. No qualification
|
||||
gate, source exclusion or production permission is relaxed by this correction.
|
||||
|
||||
@@ -336,10 +336,23 @@ These proofs establish presentation/recovery, not installed collection or delive
|
||||
|
||||
Shared workload, node, Docker-host, and resource-drawer history presentation
|
||||
keeps current readings separate from stored samples. A current metric may
|
||||
populate the legend while history is still being collected, but it must never
|
||||
be expanded into synthetic timestamps or chart geometry. An empty stored
|
||||
series renders the shared collecting-history state; zero remains a valid
|
||||
populate the legend only with the explicit `current` label, never as a stored
|
||||
reading, timestamp or chart geometry. A successful empty read says `No stored
|
||||
history in this range`; it does not imply that collection is running or that
|
||||
changing provider configuration will produce a sample. Zero remains a valid
|
||||
reported reading, while an absent metric remains unavailable.
|
||||
A lone stored series renders one point at its actual timestamp and value,
|
||||
including measured zero, never an invented line. A group with one observation
|
||||
time exposes that date visibly with `Single observation. No trend yet.` and
|
||||
retains the existing accessible observation description. Multiple series with
|
||||
separate single observations remain separate points, inspectable at their
|
||||
actual times through the existing native control. Failed same-source refreshes
|
||||
retain these points; source/range replacement clears them along with paths and
|
||||
captions. `GuestDrawerHistory.sparse.test.tsx` checks geometry, units, provenance,
|
||||
invalid data, refresh and replacement. `browser-tests/history-window.cjs` also
|
||||
checks sparse/empty/current states through the production PBS drawer and direct
|
||||
shared renderer. This is presentation proof, not provider collection or #1723
|
||||
installed acceptance.
|
||||
Shared history-chart gridlines must carry numeric labels derived from the
|
||||
plotted scale rather than semantic `Avg` / `Max` placeholders. Byte and
|
||||
byte-rate axes include their human-readable unit at each gridline, and the
|
||||
@@ -401,9 +414,20 @@ priority.
|
||||
|
||||
Expandable platform summary rows use
|
||||
`getPlatformResourceDetailRowInteractionProps` (or
|
||||
`createPlatformResourceDetailState`, which owns the same state contract) for
|
||||
whole-row pointer activation, Enter/Space keyboard activation, focus treatment,
|
||||
`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls.
|
||||
`createPlatformResourceDetailState` for their expansion state) for whole-row
|
||||
pointer activation and exclusion of embedded links and controls. The nested
|
||||
native disclosure button owns keyboard activation, focus and
|
||||
`aria-expanded` / `aria-controls`; static table rows are not duplicate controls.
|
||||
The shared `TableRow` installs a non-activating native click target only while
|
||||
an `onClick` action exists. WebKit touch must be able to synthesize that row's
|
||||
click, but actions remain document-delegated so embedded controls can stop
|
||||
propagation before the row action runs. Do not move the action into the native
|
||||
compatibility listener or add touch-end activation that opens rows during
|
||||
scrolling. Explicit caller-owned native listeners remain caller-owned.
|
||||
`Table.test.tsx` covers native binding, removal, single activation, child
|
||||
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
|
||||
The production PBS table/drawer browser fixture exercises first-tap disclosure
|
||||
and History in WebKit phone emulation, not installed collection or devices.
|
||||
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
|
||||
is visually removed on phone layouts where the complete row is the touch target;
|
||||
provider tables must not add a second mobile chevron. When row activation
|
||||
@@ -697,7 +721,7 @@ the synthetic estate continues updating, plus the alert timeline and note form.
|
||||
|
||||
The Backups surface passes its complete deduplicated route inventory to the PBS table. Only PBS servers render as rows; other resources supply correlation inputs. Preserve PBS drawer identity and use the correlated canonical history target. Missing disk utilisation does not gate CPU/memory history.
|
||||
|
||||
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
|
||||
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no stored host series and renders the no-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
|
||||
|
||||
A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines.
|
||||
|
||||
|
||||
@@ -876,11 +876,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
the migration boundary.
|
||||
11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx`
|
||||
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
|
||||
A current metric may remain visible in a chart legend while history is
|
||||
accumulating, but it must never be expanded into synthetic timestamps or a
|
||||
flat line. Fewer than two stored points remains `Collecting history` and
|
||||
only metrics-store samples may contribute chart geometry, bounds, or hover
|
||||
inspection. Current-value legends must preserve the same availability
|
||||
A current metric may remain visible in a chart legend only when labelled
|
||||
`current`; it must never be expanded into synthetic timestamps or a flat
|
||||
line. One stored sample is a visible point at its actual time, with a dated
|
||||
single-observation caption rather than a collecting claim or invented trend.
|
||||
Successful empty reads say `No stored history in this range`. Only
|
||||
metrics-store samples may contribute chart geometry, bounds, or hover
|
||||
inspection, including valid measured zero. This presentation adds no fetch,
|
||||
point expansion, polling or retention budget. Verify sparse and mixed-series
|
||||
geometry and failed-refresh/source-change behaviour in
|
||||
`GuestDrawerHistory.sparse.test.tsx`, with rendered phone/desktop coverage in
|
||||
`browser-tests/history-window.cjs`. Current-value legends preserve the same availability
|
||||
semantics as workload rows: the poller's negative unknown-disk sentinel is
|
||||
unavailable and renders `-`, never a fabricated negative percentage.
|
||||
History cards must let the plot area stretch to the card height instead of
|
||||
|
||||
@@ -5241,6 +5241,7 @@
|
||||
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
|
||||
"frontend-modern/src/components/Toast/Toast.tsx",
|
||||
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
|
||||
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
|
||||
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
|
||||
@@ -5947,6 +5948,24 @@
|
||||
"frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts",
|
||||
"frontend-modern/src/pages/__tests__/Docs.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer-metric-history",
|
||||
"label": "shared drawer stored/current history proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx",
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx",
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx",
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx",
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx",
|
||||
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx"
|
||||
]
|
||||
}
|
||||
],
|
||||
"match_files": null
|
||||
|
||||
@@ -2640,6 +2640,11 @@ vdev layout is reported` in
|
||||
|
||||
## Current State
|
||||
|
||||
### Credential-safe Proxmox bootstrap (1 October 2026)
|
||||
|
||||
The shared Proxmox setup boundary now receives its bootstrap credential from private terminal input via a bounded 0700/0600 file, before native setup mutation, rather than from copied shell literals or newly generated token-bearing download URLs. Existing registration grant binding, PVE/PBS monitoring scopes, and explicit non-rotating Audit/Repair remain unchanged. A private-file validation failure stops setup, and failed attempted registration exits nonzero with fixed guidance rather than echoed credential-bearing responses. This supersedes historical shared-boundary requirements for token-bearing commands and manual download URLs; it does not change storage permissions, recovery or retention.
|
||||
|
||||
|
||||
### Update progress stream delivery
|
||||
|
||||
Update progress delivery changes observation only. Streaming or polling a
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
One short paragraph explaining the customer outcome of the release. Lead with
|
||||
what feels better or works now, not how it was implemented.
|
||||
|
||||
## Highlights
|
||||
|
||||
- One to three short plain-text outcomes. Keep each under 140 characters with
|
||||
no Markdown, links or issue references so the in-app highlights stay readable.
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- Explain a change in plain language, where users notice it and why it matters.
|
||||
|
||||
From v6.4.6 onward, group related changes under the relevant reader-facing
|
||||
headings: Alerts and notifications, Disks and storage, Proxmox, PBS and backups,
|
||||
TrueNAS, vSphere and Docker, Install, updates and agents, Updates and agents,
|
||||
Pulse Pro, AI and hosted, Monitoring and service health, Security, and Other
|
||||
improvements. Omit unused groups. A narrow patch can keep the simpler section
|
||||
below instead. Do not repeat a change between groups or add empty sections.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **Short outcome** - Explain where users notice it and why it matters.
|
||||
@@ -12,7 +28,7 @@ what feels better or works now, not how it was implemented.
|
||||
|
||||
Use a concise set of meaningful improvements. A narrow patch may use fewer
|
||||
rather than padding the notes with internal work. Each user-visible change
|
||||
belongs in this list exactly once. Prefer observable behavior over component
|
||||
belongs in the grouped changes exactly once. Prefer observable behavior over component
|
||||
names, group related implementation work into one user-recognizable theme, and
|
||||
use plain language. Each complete bullet, including Markdown links, must be no
|
||||
more than 260 characters. New release notes must not contain semicolons or em
|
||||
@@ -33,6 +49,12 @@ constraints, and optional per-pass traces.
|
||||
Do not add a separate `Fixes` section. That shape encourages the same change to
|
||||
be described twice as both an improvement and a fix.
|
||||
|
||||
## Known issues
|
||||
|
||||
Describe remaining user symptoms and an actionable workaround or next step.
|
||||
Omit this section when there are none. Do not substitute internal verification
|
||||
status for information users can act on.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
Include only compatibility, migration, signing, companion-app, known-risk, or
|
||||
|
||||
@@ -138,7 +138,15 @@ const { chromium, firefox, webkit } = require('playwright');
|
||||
}
|
||||
if (state === 'failure')
|
||||
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
|
||||
return route.fulfill({ json: response(type, id, range, state === 'empty') });
|
||||
const result = response(type, id, range, state === 'empty');
|
||||
if (state === 'sparse')
|
||||
result.metrics = {
|
||||
cpu: [point(5, 0)],
|
||||
netin: [point(5, 0)],
|
||||
diskwrite: [point(5, 0)],
|
||||
temperature: [point(5, 50)],
|
||||
};
|
||||
return route.fulfill({ json: result });
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
@@ -336,14 +344,54 @@ const { chromium, firefox, webkit } = require('playwright');
|
||||
);
|
||||
await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) });
|
||||
progress('replacement-window-verified', { theme });
|
||||
state = 'empty';
|
||||
state = 'sparse';
|
||||
await detail.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0,
|
||||
);
|
||||
await detail.screenshot({ path: path.join(artifacts, `sparse-${theme}.png`) });
|
||||
const sparseObservation = {
|
||||
theme,
|
||||
pointCount: await detail.locator('[data-history-observation]').count(),
|
||||
collectingClaims: await detail.getByText('Collecting history', { exact: true }).count(),
|
||||
descriptions: await detail
|
||||
.getByTestId('guest-history-plot')
|
||||
.evaluateAll((plots) =>
|
||||
plots.map(
|
||||
(plot) => document.getElementById(plot.getAttribute('aria-describedby'))?.textContent,
|
||||
),
|
||||
),
|
||||
};
|
||||
progress('sparse-observation', sparseObservation);
|
||||
assert.equal(sparseObservation.pointCount, 4, 'each lone series must be visible');
|
||||
assert.equal(sparseObservation.collectingClaims, 0);
|
||||
assert.equal(await detail.getByRole('slider').count(), 0);
|
||||
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
|
||||
for (const dot of await detail.locator('[data-history-observation]').all()) {
|
||||
assert.ok(Math.abs(Number(await dot.getAttribute('cx')) - expectedX(5, '1h')) < 0.01);
|
||||
assert.ok(Number.isFinite(Number(await dot.getAttribute('cy'))));
|
||||
}
|
||||
await verifyWindow(detail, '1h');
|
||||
state = 'failure';
|
||||
await detail.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await detail
|
||||
.getByText('History refresh failed. Showing previously loaded history.')
|
||||
.waitFor();
|
||||
assert.equal(await detail.locator('[data-history-observation]').count(), 4);
|
||||
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
|
||||
state = 'empty';
|
||||
await detail.getByRole('button', { name: 'Retry history' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-history-observation]').length === 0,
|
||||
);
|
||||
await verifyWindow(detail, '1h');
|
||||
assert.equal(await detail.getByRole('slider').count(), 0);
|
||||
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4);
|
||||
assert.equal(
|
||||
await detail.getByText('No stored history in this range', { exact: true }).count(),
|
||||
4,
|
||||
);
|
||||
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 0);
|
||||
await detail.screenshot({ path: path.join(artifacts, `empty-${theme}.png`) });
|
||||
state = 'success';
|
||||
await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d');
|
||||
await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor();
|
||||
@@ -354,6 +402,23 @@ const { chromium, firefox, webkit } = require('playwright');
|
||||
assert.equal(requests.length, readsBeforeLock);
|
||||
assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0);
|
||||
assert.equal(errors.length, 0, errors.join('\n'));
|
||||
// The direct production renderer deliberately supplies current readings.
|
||||
// A live fallback must be labelled, never plotted or attributed to a stored time.
|
||||
state = 'empty';
|
||||
await page.goto('http://127.0.0.1:5225/browser-tests/pbs-history-refresh.html');
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
detail = page.getByTestId('history-refresh-fixture');
|
||||
await detail.getByText('No stored history in this range', { exact: true }).first().waitFor();
|
||||
const liveCPU = detail.locator(
|
||||
'[data-history-group="utilization"] [data-history-current="cpu"]',
|
||||
);
|
||||
assert.match((await liveCPU.innerText()).replace(/\s+/g, ' '), /CPU\s*42\.0%\s*current/);
|
||||
assert.equal(await detail.locator('[data-history-observation]').count(), 0);
|
||||
assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0);
|
||||
await detail.screenshot({ path: path.join(artifacts, `current-empty-${theme}.png`) });
|
||||
observations.push({ theme, requests, errors, utilizationX, networkX });
|
||||
progress('completed-theme', { theme });
|
||||
await page.close();
|
||||
|
||||
@@ -209,7 +209,10 @@ const { chromium } = require('playwright');
|
||||
await page.getByRole('button', { name: 'Switch to service target', exact: true }).click();
|
||||
await status.getByText('Failed to load history data', { exact: true }).waitFor();
|
||||
assert.equal(await plots.count(), 0);
|
||||
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 0);
|
||||
assert.equal(
|
||||
await detail.getByText('No stored history in this range', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await detail.getByText(/previously loaded/).count(), 0);
|
||||
await checkLayout();
|
||||
await screenshot('replacement-failed');
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Browser checks only; server responses are synthetic, not installed bootstrap.
|
||||
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';
|
||||
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'),
|
||||
server: { host: '127.0.0.1', port: 5237, strictPort: true },
|
||||
});
|
||||
const engine = process.argv.includes('--phone') ? 'webkit' : 'chromium';
|
||||
const width = engine === 'webkit' ? 390 : 1280;
|
||||
const artifacts = path.join(root, 'node_modules', `proxmox-private-${engine}`);
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
let browser;
|
||||
const observations = [];
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await { chromium, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const type of ['pve', 'pbs']) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 900 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.addInitScript(() => {
|
||||
window.__copies = [];
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: async (value) => {
|
||||
window.__copies.push(value);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
let count = 0;
|
||||
let holdEndpoint, releaseHeld, sawHeld;
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === holdEndpoint) {
|
||||
holdEndpoint = undefined;
|
||||
await new Promise((resolve) => {
|
||||
releaseHeld = resolve;
|
||||
sawHeld();
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/setup-script-url') {
|
||||
count++;
|
||||
const body = route.request().postDataJSON();
|
||||
assert.equal(body.type, type);
|
||||
const scriptURL = `http://127.0.0.1:5237/api/setup-script?type=${type}`;
|
||||
const command = `( curl -fsSL '${scriptURL}' -o "$install_script"; sudo bash -c 'read -r -s pulse_token; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"'; )`;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
type,
|
||||
host: body.host,
|
||||
url: scriptURL,
|
||||
downloadURL: scriptURL,
|
||||
scriptFileName: `pulse-setup-${type}.sh`,
|
||||
command,
|
||||
commandWithEnv: command,
|
||||
commandWithoutEnv: command,
|
||||
setupToken: 'a'.repeat(32),
|
||||
tokenHint: 'aaa…aaa',
|
||||
expires: Math.floor(Date.now() / 1000) + 300,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/agent-install-command') {
|
||||
const body = route.request().postDataJSON();
|
||||
assert.equal(body.type, type);
|
||||
assert.equal(body.enableCommands, false);
|
||||
assert.equal(body.insecure, false);
|
||||
return route.fulfill({
|
||||
json: {
|
||||
command:
|
||||
'( bash "$install_script" --preflight-only; sudo bash -c \'read -r -s pulse_token; bash "$1" --token-file "$token_file" --enable-proxmox\'; )',
|
||||
token: 'b'.repeat(32),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/setup-script') {
|
||||
assert.ok(!url.searchParams.has('setup_token'));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/x-shellscript; charset=utf-8',
|
||||
headers: { 'Content-Disposition': `attachment; filename="pulse-setup-${type}.sh"` },
|
||||
body: '#!/bin/bash\n# no credential in the download\n',
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: { data: [] } });
|
||||
});
|
||||
await page.goto(
|
||||
`http://127.0.0.1:5237/browser-tests/proxmox-private-input.html?type=${type}`,
|
||||
);
|
||||
await page.locator('h1').waitFor();
|
||||
if (engine === 'webkit')
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type,
|
||||
errors,
|
||||
buttons: await page.getByRole('button').allTextContents(),
|
||||
body: (await page.locator('body').innerText()).slice(0, 300),
|
||||
}),
|
||||
);
|
||||
const copy = page.getByRole('button', { name: 'Copy command', exact: true });
|
||||
await copy.first().click();
|
||||
let dialog = page.getByRole('dialog', { name: 'API token ready' });
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
return panel && getComputedStyle(panel).opacity === '1';
|
||||
});
|
||||
assert.ok((await dialog.innerText()).includes('silent “Pulse setup token” prompt'));
|
||||
assert.ok(!(await page.evaluate(() => window.__copies[0])).includes('a'.repeat(32)));
|
||||
await page.screenshot({ path: path.join(artifacts, `${type}-setup.png`), fullPage: true });
|
||||
const tokenButton = dialog.getByRole('button', { name: 'Copy token', exact: true });
|
||||
await tokenButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'a'.repeat(32));
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await copy.first().click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(count, 1, 'live setup artifact must be reused');
|
||||
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Close setup', exact: true }).click();
|
||||
assert.equal(await page.getByTestId('cache-state').innerText(), 'empty');
|
||||
await page.getByRole('button', { name: 'Reopen setup', exact: true }).click();
|
||||
await copy.first().click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(count, 2, 'closed modal must not retain its setup secret');
|
||||
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
||||
await page.getByText('Alternative: Download script manually', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Download setup script', exact: true }).click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(count, 2, 'download must reuse the live credential-free artifact');
|
||||
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: type === 'pbs' ? 'Copy to clipboard' : 'Copy command',
|
||||
exact: true,
|
||||
})
|
||||
.first()
|
||||
.click();
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
return panel && getComputedStyle(panel).opacity === '1';
|
||||
});
|
||||
assert.ok((await dialog.innerText()).includes('silent “Pulse agent token” prompt'));
|
||||
assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes('b'.repeat(32)));
|
||||
await page.screenshot({ path: path.join(artifacts, `${type}-agent.png`), fullPage: true });
|
||||
await dialog.getByRole('button', { name: 'Copy token', exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'b'.repeat(32));
|
||||
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
||||
// A late issuance must not repopulate a closed modal's secret cache
|
||||
// or reopen the global reveal dialog after the user has left the flow.
|
||||
for (const endpoint of ['/api/setup-script-url', '/api/agent-install-command']) {
|
||||
const closeSetup = page.getByRole('button', { name: 'Close setup', exact: true });
|
||||
if (await closeSetup.count()) await closeSetup.click();
|
||||
await page.getByRole('button', { name: 'Reopen setup', exact: true }).click();
|
||||
if (endpoint.includes('agent-install'))
|
||||
await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click();
|
||||
else await page.getByRole('button', { name: /Connect via API/ }).click();
|
||||
holdEndpoint = endpoint;
|
||||
let deadline;
|
||||
const held = new Promise((resolve, reject) => {
|
||||
sawHeld = resolve;
|
||||
deadline = setTimeout(() => reject(new Error('held request not observed')), 15000);
|
||||
});
|
||||
const nextCopy = page
|
||||
.getByRole('button', {
|
||||
name:
|
||||
endpoint.includes('agent-install') && type === 'pbs'
|
||||
? 'Copy to clipboard'
|
||||
: 'Copy command',
|
||||
exact: true,
|
||||
})
|
||||
.first();
|
||||
await nextCopy.click();
|
||||
await held;
|
||||
clearTimeout(deadline);
|
||||
await page.getByRole('button', { name: 'Close setup', exact: true }).click();
|
||||
const completed = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === endpoint,
|
||||
);
|
||||
releaseHeld();
|
||||
await completed;
|
||||
await page.waitForTimeout(200); // allow the actual promise continuation, not an API retry
|
||||
assert.equal(await page.getByTestId('cache-state').innerText(), 'empty');
|
||||
assert.equal(await page.getByRole('dialog').count(), 0);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({
|
||||
type,
|
||||
engine,
|
||||
width,
|
||||
setupRequests: count,
|
||||
credentialFreeCopies: true,
|
||||
separateTokenCopy: true,
|
||||
cacheClose: true,
|
||||
lateIssuanceDiscarded: true,
|
||||
commandsEnabled: false,
|
||||
insecure: false,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
playwright: require('playwright/package.json').version,
|
||||
browser: browser.version(),
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify({ engine, browser: browser.version(), observations, artifacts }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Proxmox private input fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./proxmox-private-input.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
// Real settings state, setup section and existing token dialog; synthetic APIs.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import type { NodeConfig } from '../src/types/nodes';
|
||||
import { render } from 'solid-js/web';
|
||||
import { useNodeModalState } from '../src/components/Settings/useNodeModalState';
|
||||
import { NodeModalSetupGuideSection } from '../src/components/Settings/NodeModalSetupGuideSection';
|
||||
import { TokenRevealDialog } from '../src/components/TokenRevealDialog';
|
||||
import '../src/index.css';
|
||||
|
||||
const Fixture = () => {
|
||||
const type = new URLSearchParams(location.search).get('type') === 'pbs' ? 'pbs' : 'pve';
|
||||
const [open, setOpen] = createSignal(true);
|
||||
const props = {
|
||||
get isOpen() {
|
||||
return open();
|
||||
},
|
||||
nodeType: type,
|
||||
prefillNode: {
|
||||
type,
|
||||
name: `${type} fixture`,
|
||||
host: `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`,
|
||||
verifySSL: true,
|
||||
} as NodeConfig,
|
||||
onClose() {
|
||||
setOpen(false);
|
||||
},
|
||||
onSave: async () => {},
|
||||
};
|
||||
const state = useNodeModalState(props);
|
||||
return (
|
||||
<main class="mx-auto max-w-2xl p-4 bg-base text-base-content">
|
||||
<h1 class="text-lg mb-3">Connect a Proxmox server</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="border border-border p-3 mb-3"
|
||||
onClick={() => {
|
||||
setOpen(!open());
|
||||
if (open())
|
||||
state.updateField('host', `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`);
|
||||
}}
|
||||
>
|
||||
{open() ? 'Close setup' : 'Reopen setup'}
|
||||
</button>
|
||||
<Show when={open()}>
|
||||
<NodeModalSetupGuideSection modalProps={props} state={state} />
|
||||
</Show>
|
||||
<output data-testid="cache-state">
|
||||
{state.quickSetupCommandReady() ? 'ready' : 'empty'}
|
||||
</output>
|
||||
<TokenRevealDialog />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
@@ -0,0 +1,245 @@
|
||||
// Real production table rows and PBS drawers; synthetic bounded HTTP data.
|
||||
// Do not instrument rows with native listeners: doing so masks the WebKit bug.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, firefox, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const engine = process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'webkit';
|
||||
const theme = process.argv.includes('--dark') ? 'dark' : 'light';
|
||||
const width = process.argv.includes('--desktop') ? 1365 : 390;
|
||||
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
|
||||
const out = path.join(root, 'node_modules', `table-row-touch-${engine}-${width}-${theme}`);
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
fs.chmodSync(out, 0o755);
|
||||
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: fs.mkdtempSync(path.join(out, 'vite-cache-')),
|
||||
server: { host: '127.0.0.1', port: 5227, strictPort: true },
|
||||
});
|
||||
const sourceHashes = Object.fromEntries(
|
||||
['src/components/shared/Table.tsx', 'package-lock.json'].map((file) => [
|
||||
file,
|
||||
createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(root, file)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
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,
|
||||
);
|
||||
let browser, page;
|
||||
const errors = [],
|
||||
requests = [],
|
||||
observations = [];
|
||||
const snapshot = async (name) => {
|
||||
const file = path.join(out, name + '.png');
|
||||
await page.screenshot({ path: file });
|
||||
fs.chmodSync(file, 0o644);
|
||||
};
|
||||
const activate = (locator) => (width === 390 ? locator.tap() : locator.click());
|
||||
const noOverflow = async () =>
|
||||
assert.ok(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
'document overflow',
|
||||
);
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await { chromium, firefox, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
page.setDefaultTimeout(20_000);
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5227') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
requests.push({ path: url.pathname, search: url.search, method: route.request().method() });
|
||||
assert.equal(route.request().method(), 'GET');
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const id = url.searchParams.get('resourceId');
|
||||
assert.ok(['vm-one', 'vm-two', 'agent-three'].includes(id), `unexpected target ${id}`);
|
||||
const end = Date.UTC(2026, 9, 1, 12);
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: url.searchParams.get('resourceType'),
|
||||
resourceId: id,
|
||||
range: url.searchParams.get('range'),
|
||||
start: end - 86400000,
|
||||
end,
|
||||
source: 'store',
|
||||
metrics: {
|
||||
cpu: [
|
||||
{ timestamp: end - 600000, value: 20 },
|
||||
{ timestamp: end - 300000, value: 30 },
|
||||
],
|
||||
memory: [{ timestamp: end - 300000, value: 40 }],
|
||||
netin: [
|
||||
{ timestamp: end - 600000, value: 1024 },
|
||||
{ timestamp: end - 300000, value: 2048 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: { data: [], enabled: false } });
|
||||
});
|
||||
console.log(JSON.stringify({ stage: 'production-pbs', engine, theme, width, sourceHashes }));
|
||||
await page.goto('http://127.0.0.1:5227/browser-tests/pbs-identity-boundary.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'Corroborate links', exact: true }));
|
||||
for (const suffix of ['one', 'two', 'three']) {
|
||||
const cell = page.locator(`td[title="backup-connection-${suffix} · tank"]`);
|
||||
const disclosure = cell.getByRole('button');
|
||||
assert.equal(await disclosure.getAttribute('aria-expanded'), 'false');
|
||||
// No focus, force, dispatchEvent, synthetic click or added event listeners.
|
||||
await activate(cell);
|
||||
await page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`).waitFor();
|
||||
assert.equal(await disclosure.getAttribute('aria-expanded'), 'true');
|
||||
const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`);
|
||||
observations.push({ suffix, firstActivationOpened: true });
|
||||
await noOverflow();
|
||||
if (suffix === 'one') await snapshot('pbs-first-activation');
|
||||
await activate(detail.getByRole('tab', { name: 'History', exact: true }));
|
||||
await detail.getByTestId('guest-history-plot').first().locator('path').first().waitFor();
|
||||
assert.equal(
|
||||
await disclosure.getAttribute('aria-expanded'),
|
||||
'true',
|
||||
'tab must not retoggle row',
|
||||
);
|
||||
if (suffix === 'three') await snapshot('pbs-third-history');
|
||||
// Close by the same ordinary cell; nested History input cannot consume it.
|
||||
await activate(cell);
|
||||
assert.equal(await disclosure.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(await detail.count(), 0);
|
||||
}
|
||||
const keyboardCell = page.locator('td[title="backup-connection-one · tank"]');
|
||||
const keyboardDisclosure = keyboardCell.getByRole('button');
|
||||
await keyboardDisclosure.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'false');
|
||||
assert.ok(requests.some((request) => request.search.includes('resourceId=vm-one')));
|
||||
assert.ok(requests.some((request) => request.search.includes('resourceId=vm-two')));
|
||||
assert.ok(requests.some((request) => request.search.includes('resourceId=agent-three')));
|
||||
|
||||
console.log(JSON.stringify({ stage: 'shared-controls', engine, theme, width }));
|
||||
await page.goto('http://127.0.0.1:5227/browser-tests/table-row-touch.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
const state = async () =>
|
||||
JSON.parse(await page.getByRole('status', { name: 'Action state' }).innerText());
|
||||
const name = page.getByText('Shared resource name', { exact: true });
|
||||
await name.waitFor();
|
||||
await activate(name);
|
||||
assert.equal((await state()).rowActions, 1);
|
||||
assert.equal((await state()).expanded, true);
|
||||
await activate(page.getByRole('button', { name: 'Child action', exact: true }));
|
||||
await activate(page.getByRole('link', { name: 'Native link' }));
|
||||
assert.equal(new URL(page.url()).hash, '#native-link');
|
||||
await activate(page.getByRole('checkbox', { name: 'Child checkbox' }));
|
||||
assert.equal(await page.getByRole('checkbox').isChecked(), true);
|
||||
assert.equal((await state()).rowActions, 1, 'nested controls must not run row action');
|
||||
assert.equal((await state()).childActions, 2);
|
||||
const toggle = page.getByTestId('control-row').locator('[data-row-action="true"]');
|
||||
await activate(toggle);
|
||||
assert.equal((await state()).rowActions, 2, 'nested disclosure must toggle once');
|
||||
await toggle.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal((await state()).rowActions, 3);
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal((await state()).rowActions, 4);
|
||||
await activate(page.getByRole('button', { name: 'Toggle row action' }));
|
||||
await activate(name);
|
||||
assert.equal((await state()).rowActions, 4);
|
||||
await activate(page.getByRole('button', { name: 'Toggle row action' }));
|
||||
await activate(name);
|
||||
assert.equal((await state()).rowActions, 5);
|
||||
await activate(page.getByText('Static row', { exact: true }));
|
||||
assert.equal((await state()).rowActions, 5);
|
||||
await activate(page.getByText('Grid resource', { exact: true }));
|
||||
assert.equal((await state()).gridActions, 1);
|
||||
await activate(page.getByRole('button', { name: 'Grid child action', exact: true }));
|
||||
assert.equal((await state()).gridActions, 1);
|
||||
assert.equal((await state()).childActions, 3);
|
||||
await noOverflow();
|
||||
await snapshot('shared-controls');
|
||||
assert.deepEqual(errors, []);
|
||||
const result = {
|
||||
engine,
|
||||
browserVersion: browser.version(),
|
||||
playwrightVersion,
|
||||
theme,
|
||||
width,
|
||||
sourceHashes,
|
||||
observations,
|
||||
requests,
|
||||
controlState: await state(),
|
||||
errors,
|
||||
result: 'passed',
|
||||
};
|
||||
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n', {
|
||||
mode: 0o644,
|
||||
});
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
if (page) await snapshot('failure').catch(() => {});
|
||||
fs.writeFileSync(
|
||||
path.join(out, 'failure.json'),
|
||||
JSON.stringify(
|
||||
{ error: String(error), errors, requests, observations, sourceHashes },
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shared table touch verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/table-row-touch.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
// Real shared table/grid actions with synthetic data, not an installed journey.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Table, TableBody, TableCell, TableRow } from '../src/components/shared/Table';
|
||||
import { SummaryRowActionButton } from '../src/components/shared/SummaryRowActionButton';
|
||||
import { PulseDataGrid } from '../src/components/shared/PulseDataGrid';
|
||||
import '../src/index.css';
|
||||
|
||||
function Fixture() {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const [rowActions, setRowActions] = createSignal(0);
|
||||
const [childActions, setChildActions] = createSignal(0);
|
||||
const [enabled, setEnabled] = createSignal(true);
|
||||
const [gridActions, setGridActions] = createSignal(0);
|
||||
const toggle = () => {
|
||||
setRowActions((count) => count + 1);
|
||||
setExpanded((value) => !value);
|
||||
};
|
||||
const child = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setChildActions((count) => count + 1);
|
||||
};
|
||||
return (
|
||||
<main class="space-y-4 p-4">
|
||||
<h1>Shared table touch verification</h1>
|
||||
<button type="button" onClick={() => setEnabled((value) => !value)}>
|
||||
Toggle row action
|
||||
</button>
|
||||
<output aria-label="Action state" class="block break-all text-xs">
|
||||
{JSON.stringify({
|
||||
expanded: expanded(),
|
||||
rowActions: rowActions(),
|
||||
childActions: childActions(),
|
||||
enabled: enabled(),
|
||||
gridActions: gridActions(),
|
||||
})}
|
||||
</output>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow data-testid="control-row" onClick={enabled() ? toggle : undefined}>
|
||||
<TableCell>
|
||||
<SummaryRowActionButton
|
||||
kind="disclosure"
|
||||
subjectLabel="shared resource"
|
||||
expanded={expanded()}
|
||||
hideWhenRowTappableOnMobile={false}
|
||||
onAction={toggle}
|
||||
/>
|
||||
<span>Shared resource name</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button type="button" onClick={child}>
|
||||
Child action
|
||||
</button>
|
||||
<a href="#native-link" onClick={child}>
|
||||
Native link
|
||||
</a>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Child checkbox"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Static row</TableCell>
|
||||
<TableCell>No action</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<PulseDataGrid<{ id: string; name: string }>
|
||||
data={[{ id: 'grid-one', name: 'Grid resource' }]}
|
||||
keyExtractor={(row) => row.id}
|
||||
onRowClick={() => setGridActions((count) => count + 1)}
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name' },
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
render: () => (
|
||||
<button type="button" onClick={child}>
|
||||
Grid child action
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
@@ -1,795 +1,63 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9",
|
||||
"verified_at": "2026-10-01T01:42:43Z",
|
||||
"base_sha": "6b23876ec8104965c7842e194e055f3206134d4f",
|
||||
"verified_at": "2026-10-01T04:21:56.727078Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
|
||||
"frontend-modern/src/api/nodes.ts",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
|
||||
"frontend-modern/src/components/Settings/useNodeModalState.ts",
|
||||
"frontend-modern/src/stores/tokenReveal.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99"
|
||||
"frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41",
|
||||
"frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd",
|
||||
"frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)"
|
||||
"/browser-tests/proxmox-private-input.html?type=pve",
|
||||
"/browser-tests/proxmox-private-input.html?type=pbs"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot",
|
||||
"all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels",
|
||||
"three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities",
|
||||
"same-time observations occupy the same horizontal coordinate across groups with different sample coverage",
|
||||
"failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window",
|
||||
"held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window",
|
||||
"empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read"
|
||||
"Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution",
|
||||
"Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text",
|
||||
"PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen",
|
||||
"Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog",
|
||||
"Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged",
|
||||
"Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)",
|
||||
"focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network",
|
||||
"Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads",
|
||||
"fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range"
|
||||
"Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog",
|
||||
"Close/reopen setup; download script; switch API/Agent paths",
|
||||
"Hold setup/agent response, close modal, then release response and verify no late reveal/cache"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log"
|
||||
],
|
||||
"notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881",
|
||||
"verified_at": "2026-10-01T00:37:51Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp",
|
||||
"a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data",
|
||||
"zero-valued lone observations can be inspected without a synthetic trend",
|
||||
"genuinely aligned series share cursor geometry and their own observed values",
|
||||
"network directions preserve independently missing and zero rates",
|
||||
"native keyboard/touch selection takes precedence over pointer movement",
|
||||
"failed same-source refresh retains correctly attributed observations and a visible warning",
|
||||
"a successful refresh updates the hovered timestamp values without choosing other latest metrics",
|
||||
"held service-target replacement has no former-host observations, cursor or dated pointer description"
|
||||
],
|
||||
"interactions": [
|
||||
"move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry",
|
||||
"leave the plot to restore latest/current legends; verify pointer inspection makes no API reads",
|
||||
"focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection",
|
||||
"Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area",
|
||||
"fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"browser_versions": {
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log"
|
||||
],
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
|
||||
"verified_at": "2026-09-30T23:28:26Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark ordered and sparse stored series",
|
||||
"native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface",
|
||||
"missing series at a selected timestamp stay missing, not nearby or live values",
|
||||
"same-source refresh adds samples without changing the selected timestamp or keyboard focus",
|
||||
"failed refresh retains inspectable matching observations and honest warning",
|
||||
"single stored observation has a dated description but no fabricated trend or pointless slider",
|
||||
"empty store/current readings expose no inspection control",
|
||||
"held replacement target has no former-host points or selection; new target starts at its own latest observation",
|
||||
"licence-locked range has no reads or inspection action"
|
||||
],
|
||||
"interactions": [
|
||||
"Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads",
|
||||
"refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp",
|
||||
"move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works",
|
||||
"Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values",
|
||||
"fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"browser_versions": {
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log"
|
||||
],
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
|
||||
"verified_at": "2026-09-30T22:39:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
|
||||
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
|
||||
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
|
||||
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
|
||||
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
|
||||
],
|
||||
"interactions": [
|
||||
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
|
||||
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
|
||||
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
|
||||
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
|
||||
],
|
||||
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
|
||||
"artifacts": [
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
|
||||
],
|
||||
"notes": [
|
||||
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
|
||||
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
|
||||
"verified_at": "2026-09-30T20:12:09Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"corroborated host with seven observed metric paths",
|
||||
"uncached range replacement pending with no old paths",
|
||||
"new range loaded before the old range response",
|
||||
"withdrawn host link with replacement target pending",
|
||||
"replacement target returns 503 with no former-host history",
|
||||
"service-only response retains CPU/memory, not host disk/network/I/O",
|
||||
"locked 14-day range issues no history read"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop keyboard Enter / phone row click to open PBS details",
|
||||
"select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
|
||||
"withdraw third host link while service GET is held, then return 503",
|
||||
"select 6h after failure and inspect the service-only response",
|
||||
"select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
|
||||
],
|
||||
"notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
|
||||
"verified_at": "2026-09-30T01:40:13Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"three unlinked PBS services alongside six label/IP-colliding Agents",
|
||||
"explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication",
|
||||
"unchanged links with host rows omitted and restored",
|
||||
"withdrawn third link with real and colliding host rows still present",
|
||||
"fresh exact PBS nodeName to Agent hostname match with mismatched display labels"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop keyboard disclosure and phone row activation for all three PBS drawers",
|
||||
"inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges",
|
||||
"join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal",
|
||||
"24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png"
|
||||
],
|
||||
"notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
|
||||
"verified_at": "2026-09-29T23:04:22Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
|
||||
},
|
||||
"routes": [
|
||||
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
|
||||
"host rows omitted in a transient refresh while the backend link is unchanged",
|
||||
"third backend link withdrawn while the Agent row remains omitted"
|
||||
],
|
||||
"interactions": [
|
||||
"open all three PBS drawers and inspect their Metrics Target identity",
|
||||
"omit host rows and confirm targets stay on the correlated hosts",
|
||||
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
|
||||
],
|
||||
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
|
||||
],
|
||||
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3",
|
||||
"verified_at": "2026-09-29T22:13:56Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/ContainerUpdateBadge.tsx",
|
||||
"frontend-modern/src/components/shared/containerUpdateBadgeModel.ts",
|
||||
"frontend-modern/src/components/shared/useContainerUpdateButtonState.ts",
|
||||
"frontend-modern/src/stores/containerUpdates.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9",
|
||||
"frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e",
|
||||
"frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5",
|
||||
"frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"governed update receipt pending after registry updateAvailable becomes false",
|
||||
"existing action re-opened by GET with no second plan or execute",
|
||||
"recorded completed action after a second GET changes the displayed outcome"
|
||||
],
|
||||
"interactions": [
|
||||
"inspect pending button and absence of a Current or Completed claim",
|
||||
"select Review action and inspect receipt-pending dialog",
|
||||
"select Check for receipt and inspect recorded completion",
|
||||
"assert both action requests are GET; no page errors or horizontal overflow"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-browser-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png"
|
||||
],
|
||||
"notes": "Mock-backed UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
|
||||
"verified_at": "2026-09-29T19:50:45Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/pages/Actions.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"detail for edge-a held while edge-b is selected and rendered",
|
||||
"late edge-a response cannot replace edge-b review or URL",
|
||||
"pending receipt re-read finishes after edge-b review closes"
|
||||
],
|
||||
"interactions": [
|
||||
"select edge-a then edge-b before the first detail response arrives",
|
||||
"release older response and inspect current review at desktop and phone widths",
|
||||
"start same-action receipt GET, close review, release response; no dialog reopens",
|
||||
"assert all scripted API requests are GET and no horizontal overflow or page errors"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png"
|
||||
],
|
||||
"notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
|
||||
"verified_at": "2026-09-29T19:09:52Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"fresh executing/receipt_pending, including after a same-action re-read",
|
||||
"failed status re-read with unknown outcome preserved",
|
||||
"completed action after a recorded receipt"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop pointer and phone keyboard Check for receipt",
|
||||
"GET same action under pending, read error and completion; no POST",
|
||||
"desktop and phone layout and horizontal-overflow checks"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
|
||||
],
|
||||
"notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
|
||||
"verified_at": "2026-09-29T16:42:43Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12",
|
||||
"frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"PBS API + corroborated Agent with token-auth nodeName absent",
|
||||
"same-host PVE API joined alongside a PVE-only name collision",
|
||||
"correlated host row temporarily omitted",
|
||||
"correlated host row restored"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop disclosure-button keyboard focus and Enter; phone row click",
|
||||
"open History and select 24h, 6h, 1h and 12h across topology changes",
|
||||
"join PVE API, omit host row, restore host row"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png"
|
||||
],
|
||||
"notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.",
|
||||
"preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d"
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2",
|
||||
"verified_at": "2026-09-29T12:15:52Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
|
||||
"frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts",
|
||||
"frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx",
|
||||
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
|
||||
"frontend-modern/src/i18n/messages.de.ts",
|
||||
"frontend-modern/src/i18n/messages.es.ts",
|
||||
"frontend-modern/src/i18n/messages.ts",
|
||||
"frontend-modern/src/utils/alertDestinationsPresentation.ts",
|
||||
"frontend-modern/src/utils/licensePresentation.ts",
|
||||
"frontend-modern/src/utils/relayPresentation.ts",
|
||||
"frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts",
|
||||
"frontend-modern/src/utils/selfHostedPlans.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755",
|
||||
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2",
|
||||
"frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86",
|
||||
"frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0",
|
||||
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9",
|
||||
"frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2",
|
||||
"frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205",
|
||||
"frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e",
|
||||
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880",
|
||||
"frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6",
|
||||
"frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c",
|
||||
"frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e",
|
||||
"frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/system-relay",
|
||||
"/alerts/notifications",
|
||||
"/settings/pulse-intelligence/billing/plan",
|
||||
"/settings/system-general"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 768
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt",
|
||||
"Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link",
|
||||
"Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording",
|
||||
"Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain",
|
||||
"Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link",
|
||||
"Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy"
|
||||
],
|
||||
"interactions": [
|
||||
"Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements",
|
||||
"Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow",
|
||||
"Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3",
|
||||
"verified_at": "2026-09-30T22:01:47Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark current host history",
|
||||
"real 30-second poll fails while matching points remain explicitly labelled",
|
||||
"keyboard retry held with busy/activation guard and retained points",
|
||||
"successful retry keeps keyboard focus and updates the same control",
|
||||
"real background polling supersedes a slow foreground read and settles loading",
|
||||
"late aborted response cannot replace current paths",
|
||||
"uncached service-target failure has neither former-host points nor a collecting claim",
|
||||
"service-target retry renders CPU/memory only",
|
||||
"locked range exposes no read or refresh action"
|
||||
],
|
||||
"interactions": [
|
||||
"Enter retry twice while its GET is held; exactly one retry read starts",
|
||||
"fulfil retry; inspect warning removal, changed paths and retained focus",
|
||||
"hold manual refresh across the real polling interval; inspect loading settlement and aborted read",
|
||||
"switch to service target, fail its GET, retry the same key, select locked 14d"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png"
|
||||
],
|
||||
"notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed."
|
||||
}
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ X-API-Token: <token>
|
||||
Then make a read-only request (curl 7.76 or later):
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/state/summary
|
||||
```
|
||||
|
||||
@@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled;
|
||||
do not use `--insecure`. On an authentication-enabled instance, the protected
|
||||
summary checks token access; `/api/health` is public and does not verify authentication.
|
||||
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
|
||||
403, while retaining the error response.
|
||||
403, while retaining the error response. Keep `--disable` first: it ignores
|
||||
local curl configuration that could otherwise enable credential-bearing trace output.
|
||||
|
||||
Do not paste tokens into command lines, URLs or issue reports. Keep the header
|
||||
file private and outside shared repositories and diagnostics; do not use curl
|
||||
@@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include:
|
||||
- `settings:write`
|
||||
- `agent:config:read`
|
||||
- `agent:manage`
|
||||
- `actions:plan`, `actions:approve`, `actions:execute`
|
||||
- `audit:read`
|
||||
|
||||
Endpoints that require admin access are noted below.
|
||||
|
||||
@@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta
|
||||
|
||||
The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels.
|
||||
|
||||
CLI adapter:
|
||||
Use the private header file prepared in [Authentication](#-authentication),
|
||||
with `settings:read` on the token:
|
||||
|
||||
```bash
|
||||
PULSE_API_TOKEN=your-token pulse fleet connections \
|
||||
--api-url http://localhost:7655
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/connections
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
`POST /api/actions/plan`
|
||||
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
|
||||
|
||||
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
|
||||
The existing action API separates planning, approval and execution. Run these
|
||||
steps separately, inspecting each response before moving on; do not paste the
|
||||
whole sequence as an unattended recovery script. Use the private header file
|
||||
from [Authentication](#-authentication), not a token in a command or environment
|
||||
assignment. For remote access, replace the loopback origin with your Pulse HTTPS
|
||||
URL and keep certificate verification enabled.
|
||||
|
||||
`POST /api/actions/{id}/decision`
|
||||
Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action.
|
||||
| Endpoint | Token scope and access |
|
||||
| --- | --- |
|
||||
| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. |
|
||||
| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. |
|
||||
| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. |
|
||||
| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. |
|
||||
| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. |
|
||||
| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. |
|
||||
|
||||
`POST /api/actions/{id}/execute`
|
||||
Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint.
|
||||
The legacy `ai:execute` scope also permits planning, approval and execution,
|
||||
but use the narrower action scopes when possible. Approval and execution tokens
|
||||
must be bound to an authorised user; a scope alone does not bypass approval
|
||||
policy, separation of duties or step-up requirements. Audit reads through a
|
||||
browser session additionally require admin access. A `monitoring:read` token is
|
||||
not an action-control or audit token.
|
||||
|
||||
1. **Inspect capabilities.** Choose the canonical resource `id` from
|
||||
`GET /api/resources`, not a display name or a VM number. Replace `vm:42`
|
||||
throughout these examples and URL-encode it in request paths (`vm%3A42` here).
|
||||
An empty capabilities list means there is nothing to plan for that resource.
|
||||
|
||||
CLI adapter:
|
||||
```bash
|
||||
PULSE_API_TOKEN=your-token pulse actions capabilities \
|
||||
--api-url http://localhost:7655 \
|
||||
--resource-id vm:42
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions plan \
|
||||
--api-url http://localhost:7655 \
|
||||
--request-id agent-run-123 \
|
||||
--resource-id vm:42 \
|
||||
--capability restart \
|
||||
--param mode=graceful \
|
||||
--reason "Recover after confirmed outage" \
|
||||
--requested-by agent:oncall-helper
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions decide \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_... \
|
||||
--outcome approved \
|
||||
--reason "Inside maintenance window"
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions execute \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_... \
|
||||
--reason "Execute approved recovery"
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions audit \
|
||||
--api-url http://localhost:7655 \
|
||||
--resource-id vm:42 \
|
||||
--limit 10
|
||||
|
||||
PULSE_API_TOKEN=your-token pulse actions events \
|
||||
--api-url http://localhost:7655 \
|
||||
--action-id act_...
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
|
||||
```
|
||||
|
||||
Request:
|
||||
```json
|
||||
2. **Plan only.** Use an advertised capability and its actual parameter schema;
|
||||
`restart` and `mode=graceful` are examples, not universal capabilities. Choose
|
||||
a request ID for this specific intent. Pulse derives the actor from the
|
||||
authenticated credential, not a caller-supplied `requestedBy` value.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/plan <<'JSON'
|
||||
{
|
||||
"requestId": "agent-run-123",
|
||||
"requestId": "manual-recovery-123",
|
||||
"resourceId": "vm:42",
|
||||
"capabilityName": "restart",
|
||||
"params": { "mode": "graceful" },
|
||||
"reason": "Recover after confirmed outage",
|
||||
"requestedBy": "agent:oncall-helper"
|
||||
"reason": "Recover after confirmed outage"
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Inspect the returned approval policy, blast radius, expiry and preflight checks.
|
||||
Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...`
|
||||
below with those returned values. Planning is not proof that execution is safe
|
||||
or currently available.
|
||||
|
||||
3. **Decide only if approval is required.** Use an authorised approver's private
|
||||
header file. Approve only the reviewed plan; stop on a refusal or stale-plan
|
||||
response rather than weakening the policy or creating another recovery action.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON'
|
||||
{
|
||||
"outcome": "approved",
|
||||
"reason": "Inside maintenance window",
|
||||
"planHash": "sha256:..."
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
4. **Execute separately.** Only after reviewing a successful decision, or an
|
||||
approval-free executable plan, use the executor's authorised header file:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST --data-binary @- \
|
||||
http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON'
|
||||
{
|
||||
"reason": "Execute approved recovery",
|
||||
"planHash": "sha256:..."
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
If the response is lost or times out, inspect the existing action and target
|
||||
before retrying: losing the connection does not prove that execution stopped.
|
||||
An HTTP success is not a substitute for checking the terminal result and the
|
||||
resource's observed state.
|
||||
|
||||
5. **Read the audit and events**, using a private header file with audit access:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/actions/act_.../events
|
||||
```
|
||||
|
||||
When audit logging is unavailable, use Pulse's action detail in the signed-in
|
||||
UI to inspect the existing action; do not infer success or repeat execution
|
||||
from an unavailable audit response.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"actionId": "act_...",
|
||||
"requestId": "agent-run-123",
|
||||
"requestId": "manual-recovery-123",
|
||||
"allowed": true,
|
||||
"requiresApproval": true,
|
||||
"approvalPolicy": "admin",
|
||||
@@ -525,9 +580,11 @@ Request body:
|
||||
### Setup Script (Public)
|
||||
`GET /api/setup-script`
|
||||
Returns the Proxmox/PBS setup script as a shell-script download. Accepts an
|
||||
optional temporary setup token in the `setup_token` query for embedded
|
||||
non-interactive bootstrap; otherwise the script prompts for the one-time setup
|
||||
token at runtime. Canonical callers must send a supported `type` of `pve` or
|
||||
optional legacy `setup_token` query for compatibility. Current downloads
|
||||
contain no token: paste the separately revealed token only at the silent
|
||||
terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600
|
||||
regular file in a mode-0700 directory owned by the script's user. Never put a
|
||||
token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or
|
||||
`pbs` plus non-empty `host` and `pulse_url`; the route no longer generates
|
||||
placeholder-host scripts for later repair or reconstructs Pulse identity from
|
||||
the request origin. The route now shares the same canonical type boundary as
|
||||
@@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the
|
||||
setup/bootstrap path derive from the canonical Pulse endpoint, not request-local
|
||||
host fallbacks, so setup-script and turnkey node-add flows stay on one
|
||||
deterministic `pulse-<canonical-scope-slug>` identity per Pulse instance.
|
||||
`setupToken` remains bootstrap transport data for `/api/setup-script` and
|
||||
`/api/auto-register`, while `tokenHint` is the operator-facing display field
|
||||
for quick-setup surfaces and must stay masked instead of exposing the full
|
||||
one-time token in UI copy. Shared frontend consumers may validate
|
||||
`setupToken`, but they should not retain or display it once the returned
|
||||
bootstrap artifact and `tokenHint` are available; visible quick-setup previews
|
||||
should use the non-secret `commandWithoutEnv` form while copy actions keep
|
||||
using the token-bearing `commandWithEnv` artifact, and manual download flows
|
||||
should use the token-bearing `downloadURL` artifact instead of rebuilding a
|
||||
plain setup-script URL from non-secret preview state. Non-frontend bootstrap
|
||||
consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that
|
||||
same full artifact contract too, rejecting missing or mismatched
|
||||
`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a
|
||||
reduced setup-token-only response shape.
|
||||
The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain
|
||||
identical credential-free commands. They download the complete script before
|
||||
running it and prompt silently in the root-or-sudo process. The token crosses
|
||||
the installer boundary through a private file, not process arguments or an
|
||||
exported secret. `downloadURL` equals the tokenless `url`, so a manual download
|
||||
also needs the separately revealed token at runtime. `setupToken` is used for
|
||||
`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings
|
||||
reveals the token in a separate dialog: run the command first, then copy and
|
||||
paste the token only at its prompt. The artifact is reused only for the same
|
||||
host and options while its five-minute expiry is live, and discarded when the
|
||||
setup modal closes.
|
||||
Non-frontend consumers must validate the complete artifact, including the
|
||||
canonical host, type, URLs, filename, masked hint and live expiry. Current
|
||||
Unified Agents and the shell installer also accept the coherent older-server
|
||||
artifact during upgrades, but never execute its command text. For new
|
||||
Proxmox agent enrolment against a newer server, use its current installer;
|
||||
already enrolled agents keep reporting normally.
|
||||
|
||||
### Auto-Register (Public)
|
||||
`POST /api/auto-register`
|
||||
@@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user.
|
||||
```json
|
||||
{ "id": "acme-corp", "displayName": "Acme Corporation" }
|
||||
```
|
||||
The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters.
|
||||
The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example.
|
||||
|
||||
### Get Organization
|
||||
`GET /api/orgs/{id}` (requires `settings:read`)
|
||||
@@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org.
|
||||
```json
|
||||
{ "userId": "jane", "role": "editor" }
|
||||
```
|
||||
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
|
||||
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
|
||||
|
||||
### Remove Member
|
||||
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
|
||||
@@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations.
|
||||
"accessRole": "viewer"
|
||||
}
|
||||
```
|
||||
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
|
||||
Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
|
||||
|
||||
### Delete Share
|
||||
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)
|
||||
|
||||
@@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
|
||||
|
||||
### API
|
||||
|
||||
Use a token with `audit:read`, bound to a user permitted to read audit logs,
|
||||
and the licensed `audit_logging` capability. Prepare the private header file
|
||||
in [API authentication](API.md#-authentication); never paste the token or a
|
||||
session cookie into a command. For a one-off read, you can instead open the
|
||||
API path in your signed-in Pulse browser.
|
||||
|
||||
The loopback URLs below apply on the Pulse host. For remote access, use your
|
||||
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
|
||||
keeping `--disable` first to ignore local trace/verbose defaults.
|
||||
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
|
||||
402 and 403. Share only the relevant redacted error, not the header file or
|
||||
whole audit response: events can contain usernames, client addresses and paths.
|
||||
|
||||
```bash
|
||||
# List recent events
|
||||
curl http://localhost:7655/api/audit?limit=50 \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit?limit=50'
|
||||
|
||||
# Filter by event type and date range
|
||||
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
|
||||
|
||||
# Get audit summary
|
||||
curl http://localhost:7655/api/audit/summary \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/summary
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
@@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
|
||||
Export the audit log for external analysis or compliance archival:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/audit/export \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o audit-export.json
|
||||
umask 077
|
||||
export_dir="$(mktemp -d)" &&
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--output "$export_dir/audit-export.json" \
|
||||
http://127.0.0.1:7655/api/audit/export &&
|
||||
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
|
||||
```
|
||||
|
||||
The export includes all events matching the current filter criteria.
|
||||
This creates a new private directory and prints the file's location only on
|
||||
success. It does not reuse filters selected in the UI. Treat the export as
|
||||
sensitive data; keep it outside shared repositories and issue attachments.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
|
||||
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
@@ -51,9 +51,30 @@ Each member has a role within an organization:
|
||||
|
||||
Organizations can share specific resources with other organizations:
|
||||
|
||||
- Share a VM, container, host, or storage resource with another org.
|
||||
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
|
||||
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
|
||||
- The receiving org sees shared resources alongside their own, with a share badge.
|
||||
- The receiving org must accept the share before it sees the resource alongside its own.
|
||||
|
||||
## Before Using the API
|
||||
|
||||
Create organizations and manage members and shares in the signed-in Pulse UI.
|
||||
These changes require **session-based user authentication** and the relevant
|
||||
organization role; API tokens are rejected with `403 session_required`, even
|
||||
when they have `settings:write`. The UI handles the session and CSRF protection.
|
||||
Do not extract a session cookie or CSRF token into a shell command.
|
||||
|
||||
The read-only curl examples use an org-bound token with `settings:read` and
|
||||
the private header file from [API authentication](API.md#-authentication).
|
||||
Keep the token out of command lines, URLs and reports. A token's organization
|
||||
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
|
||||
not grant it access to another organization.
|
||||
|
||||
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
|
||||
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
|
||||
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
|
||||
keep certificate verification enabled. Replace the example organization ID
|
||||
`production-datacenter` with your own. Run requests separately, and share only
|
||||
the relevant redacted error, not whole member or infrastructure responses.
|
||||
|
||||
## Managing Organizations
|
||||
|
||||
@@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations:
|
||||
|
||||
**UI:** Settings → Organization → Create Organization
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7655/api/orgs \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
|
||||
**API contract:** `POST /api/orgs`, session authentication only:
|
||||
```json
|
||||
{"id": "production-datacenter", "displayName": "Production Datacenter"}
|
||||
```
|
||||
|
||||
The creator becomes the owner. `id` is a stable ID of 1–64 characters using
|
||||
letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a
|
||||
simple lowercase/hyphen ID such as the example. `displayName` is the name
|
||||
shown in Pulse. `name` and `description` are not the creation fields.
|
||||
|
||||
### Switching Organizations
|
||||
|
||||
Use the **Org Switcher** dropdown in the header. When you switch:
|
||||
@@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch:
|
||||
|
||||
**UI:** Settings → Organization → Access
|
||||
|
||||
**API:**
|
||||
**Read-only API:**
|
||||
```bash
|
||||
# List members
|
||||
curl http://localhost:7655/api/orgs/{orgId}/members \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Add a member
|
||||
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId": "user-id", "role": "editor"}'
|
||||
|
||||
# Update role
|
||||
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"role": "admin"}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/orgs/production-datacenter/members
|
||||
```
|
||||
|
||||
**Invite or update a member:** use the Access panel as an owner or admin.
|
||||
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
|
||||
```json
|
||||
{"userId": "user-id", "role": "editor"}
|
||||
```
|
||||
|
||||
For a new member, the response is `202` with a pending invitation; the user
|
||||
must accept it in Pulse before gaining access. An existing member's role is
|
||||
updated by posting their `userId` and new role to the same endpoint, not by
|
||||
PATCHing a member URL:
|
||||
```json
|
||||
{"userId": "user-id", "role": "admin"}
|
||||
```
|
||||
|
||||
Only the current owner can transfer ownership, and only to an existing member
|
||||
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
|
||||
member update. Default-organization members cannot be managed here.
|
||||
|
||||
### Sharing Resources
|
||||
|
||||
**UI:** Settings → Organization → Sharing
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
# Create a share
|
||||
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"targetOrgId": "other-org-id",
|
||||
"resourceType": "host",
|
||||
"resourceId": "resource-id",
|
||||
"role": "viewer"
|
||||
}'
|
||||
Create the share as an owner or admin of the source organization. An owner or
|
||||
admin of the target organization then accepts it in **Sharing → Incoming**.
|
||||
Until acceptance, the share is pending and does not grant access.
|
||||
|
||||
# View incoming shares
|
||||
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
|
||||
```json
|
||||
{
|
||||
"targetOrgId": "other-org-id",
|
||||
"resourceType": "vm",
|
||||
"resourceId": "vm:101",
|
||||
"accessRole": "viewer"
|
||||
}
|
||||
```
|
||||
|
||||
Use `accessRole`, not `role`, and the resource type and ID returned by Pulse,
|
||||
not a display name. Supported types include `vm`, `system-container`, `agent`,
|
||||
`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and
|
||||
`container` are not supported. Changing a share's access role makes it pending
|
||||
again, so the target must accept the new grant.
|
||||
|
||||
**Read-only incoming shares:**
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
|
||||
```
|
||||
|
||||
## Monitoring Multiple Internal Estates
|
||||
@@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
|
||||
| `GET` | `/api/orgs` | List organizations the current user can access |
|
||||
| `POST` | `/api/orgs` | Create a new organization |
|
||||
| `GET` | `/api/orgs/{id}` | Get organization details |
|
||||
| `PATCH` | `/api/orgs/{id}` | Update organization |
|
||||
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}` | Delete organization |
|
||||
| `GET` | `/api/orgs/{id}/members` | List members |
|
||||
| `POST` | `/api/orgs/{id}/members` | Add a member |
|
||||
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
|
||||
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
|
||||
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
|
||||
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
|
||||
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
|
||||
| `POST` | `/api/orgs/{id}/shares` | Create a share |
|
||||
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
|
||||
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
|
||||
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
|
||||
|
||||
### Tenant Context
|
||||
@@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active
|
||||
2. Session cookie (browser)
|
||||
3. Falls back to the `default` organization
|
||||
|
||||
Neither the active context nor a token scope overrides organization membership
|
||||
or a token's organization binding.
|
||||
|
||||
## Storage
|
||||
|
||||
- The **default** org uses the root data directory (backward compatible).
|
||||
@@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
|
||||
|
||||
### Shared resources not appearing
|
||||
|
||||
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
|
||||
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
|
||||
2. Confirm the share role grants sufficient access.
|
||||
3. Check that the source org's resources are online.
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways:
|
||||
|
||||
### 1. Direct PBS Connection (Recommended)
|
||||
|
||||
Connect directly to your PBS server for full monitoring capabilities:
|
||||
Connect to the PBS API with a dedicated read-only token. An agent is not
|
||||
required for these API-backed readings:
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Deduplication factor and storage efficiency stats
|
||||
@@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
|
||||
- ❌ Can be slow for encrypted PBS storage
|
||||
- ❌ Limited metadata per backup
|
||||
|
||||
**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
|
||||
**Recommendation:** Start with a direct API connection when you need PBS
|
||||
datastore, job or server status beyond the PVE passthrough. Add a host agent
|
||||
only for extra local telemetry such as SMART and temperatures; see
|
||||
[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices).
|
||||
|
||||
---
|
||||
|
||||
## Setting Up Direct PBS Connection
|
||||
|
||||
### Method 1: Unified Agent Install (Recommended for Bare Metal)
|
||||
### Method 1: API-Only Connection (Recommended)
|
||||
|
||||
Install the unified agent directly on your PBS server for automatic setup:
|
||||
Use a dedicated PBS monitoring user and token, not a root password. On a new
|
||||
setup, run these commands in an administrator shell **on the PBS server**:
|
||||
|
||||
```bash
|
||||
# Run on your PBS server
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
sudo bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox --proxmox-type pbs
|
||||
```
|
||||
|
||||
The agent will:
|
||||
1. Detect it's running on a PBS server
|
||||
2. Create a `pulse-monitor@pbs` user with read-only access
|
||||
3. Generate an API token
|
||||
4. Register the PBS node with Pulse automatically
|
||||
|
||||
### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐
|
||||
|
||||
Use this when you can run a command on the PBS host but do not want to install the agent.
|
||||
|
||||
From Pulse's Settings page:
|
||||
1. Go to **Settings → Infrastructure**.
|
||||
2. Click **Add infrastructure**.
|
||||
3. Choose **Proxmox Backup Server**.
|
||||
4. Use the API-only setup path and enter your PBS server's URL.
|
||||
5. Click copy to get the setup command.
|
||||
6. Run the command on your PBS server.
|
||||
|
||||
Example (what the UI generates):
|
||||
```bash
|
||||
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
|
||||
```
|
||||
|
||||
Pulse generates that full command for you from **Settings → Infrastructure**, including
|
||||
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
|
||||
generates a scoped API token, and registers the server with Pulse.
|
||||
|
||||
> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality.
|
||||
|
||||
> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding.
|
||||
|
||||
### Method 3: Manual Token Creation
|
||||
|
||||
If you prefer manual setup:
|
||||
|
||||
```bash
|
||||
# SSH into your PBS server
|
||||
|
||||
# 1. Create a dedicated monitoring user
|
||||
proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring"
|
||||
|
||||
# 2. Grant read-only access (Audit role)
|
||||
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
||||
|
||||
# 3. Generate an API token (save the output!)
|
||||
proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
|
||||
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
||||
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'
|
||||
```
|
||||
|
||||
Copy the token value and enter it in Pulse:
|
||||
- **Token ID:** `pulse-monitor@pbs!pulse-token`
|
||||
- **Token Value:** The UUID shown after running the command
|
||||
PBS tokens have their own permissions, limited by their owning user's
|
||||
permissions. Grant `Audit` to **both the user and the token**. If either already
|
||||
exists, inspect and reuse it instead of deleting it or rerunning token creation.
|
||||
Do not disable privilege separation or grant `Admin` to work around a missing
|
||||
permission.
|
||||
|
||||
The token secret is shown once. Save it privately and enter it only in Pulse's
|
||||
**Token Value** field; do not paste it into a shell command, URL, screenshot or
|
||||
issue report. Keep any terminal recording containing that output private.
|
||||
|
||||
In Pulse:
|
||||
1. Open **Settings → Infrastructure → Add infrastructure** and choose
|
||||
**Proxmox Backup Server**.
|
||||
2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`.
|
||||
3. Select **API Token** and **Manual Token Setup**.
|
||||
4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in
|
||||
**Token Value**.
|
||||
5. Keep certificate verification enabled. For a self-signed certificate, use
|
||||
an independently verified **SSL Fingerprint**; see the TLS guidance below.
|
||||
6. Test and save the connection. Check that the expected datastores and backups
|
||||
are visible, not just that the connection test succeeds.
|
||||
|
||||
### Method 2: Optional Host Agent
|
||||
|
||||
Use this only when you also need host-local telemetry. The Linux agent normally
|
||||
runs as root; it is not required to fix API authentication or missing backup
|
||||
permissions. Review [Agent Security](AGENT_SECURITY.md) first.
|
||||
|
||||
Create a separate Pulse token in **API Access**, using the **Agent host** preset
|
||||
for reporting, configuration reads and agent management. This is a **Pulse**
|
||||
token, not the **PBS** token used above. Do not enable command execution just
|
||||
for monitoring.
|
||||
|
||||
On the PBS host, enter an administrator root shell before preparing the private
|
||||
file below. In the editor, save only the Pulse agent token, with no header or
|
||||
quotes; never put the secret in a command argument:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
|
||||
vi "$HOME/.config/pulse/pbs-agent-token"
|
||||
```
|
||||
|
||||
Download the agent installer from **your Pulse server's HTTPS address**, then
|
||||
inspect the saved script before running it. Replace the example Pulse URL in
|
||||
both commands. Stop if the download fails; run the next command only after a
|
||||
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
|
||||
the Pulse server, not the agent.
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/pbs-agent-token" \
|
||||
--enable-proxmox --proxmox-type pbs --enable-docker=false
|
||||
```
|
||||
|
||||
Do not bypass certificate checks to fetch or run the installer. Use a trusted
|
||||
Pulse certificate or a CA file you verified separately. See
|
||||
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
|
||||
After installation, check a fresh agent report and the host's
|
||||
`pulse-agent --version`; installation or hardware capacity alone does not prove
|
||||
that every CPU, memory or History reading is available. Keep the token file
|
||||
private and remove the bootstrap copy when no longer needed; do not remove the
|
||||
installed agent's runtime credential.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil
|
||||
|
||||
### "Connection Failed" Error
|
||||
|
||||
1. **Check URL:** Ensure the PBS URL is correct (default port is 8007)
|
||||
- Format: `https://pbs.example.com:8007`
|
||||
Check from the Pulse host or container's network, if possible. A request from
|
||||
another machine does not prove that Pulse can reach PBS.
|
||||
|
||||
2. **Verify token:** Test authentication:
|
||||
```bash
|
||||
curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \
|
||||
https://your-pbs:8007/api2/json/version
|
||||
```
|
||||
- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing
|
||||
and the firewall before changing credentials.
|
||||
- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse
|
||||
runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value
|
||||
through the PBS console or another already-trusted administrative channel.
|
||||
Do not accept a fingerprint solely from the failed connection. For example,
|
||||
on the PBS server you can inspect its public certificate (not its private key):
|
||||
|
||||
3. **Network access:** Ensure Pulse can reach PBS on port 8007
|
||||
```bash
|
||||
openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256
|
||||
```
|
||||
|
||||
4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings
|
||||
- **Authentication/permissions:** test a datastore request, not just `/version`.
|
||||
Prepare a private header file on the machine running curl:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-header"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-header"
|
||||
vi "$HOME/.config/pulse/pbs-header"
|
||||
```
|
||||
|
||||
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
|
||||
for the PBS token being tested:
|
||||
|
||||
```text
|
||||
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
|
||||
```
|
||||
|
||||
Then use curl 7.76 or later, with your PBS hostname:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
|
||||
--header "@$HOME/.config/pulse/pbs-header" \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore
|
||||
```
|
||||
|
||||
For curl with a private CA or self-signed certificate, add
|
||||
`--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained
|
||||
and verified through a trusted channel. Its hostname must still match. Do not
|
||||
use `--insecure` or `-k`.
|
||||
|
||||
`401` means authentication was rejected; `403` means the requested access was
|
||||
refused. Both return a non-zero curl exit while retaining the error body.
|
||||
Inspect the existing token and both `Audit` grants before replacing anything.
|
||||
A `200` response listing the expected datastore establishes access to that
|
||||
list, not successful collection of every backup, job or History graph. An
|
||||
empty list is not proof that all permissions are correct.
|
||||
|
||||
Keep the header file outside shared repositories and diagnostics. Do not share
|
||||
verbose/trace curl output, full infrastructure responses, token values or
|
||||
private keys. If help is needed, provide only the HTTP status, relevant redacted
|
||||
error and which expected reading is missing. Do not clear History or recreate a
|
||||
working connection to make missing data look resolved.
|
||||
|
||||
### Slow Backup Loading
|
||||
|
||||
@@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE:
|
||||
|
||||
### Duplicate Backups
|
||||
|
||||
If you see the same backup twice:
|
||||
- This shouldn't happen—Pulse deduplicates by VMID and timestamp
|
||||
- If it does occur, the direct PBS version takes priority
|
||||
- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')`
|
||||
Check the source, datastore, namespace, guest type/ID and backup time on each
|
||||
entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone
|
||||
are not proof of a duplicate. Keep both records while checking their origin.
|
||||
If the same backup remains listed twice, report those redacted distinctions and
|
||||
whether each entry came from direct PBS or PVE passthrough. Do not delete backups
|
||||
or change retention to hide a display problem.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator:
|
||||
- **"PBS"** badge alone = Direct PBS connection (full data)
|
||||
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)
|
||||
|
||||
Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities.
|
||||
When the same backup is reconciled across both sources, Pulse prefers the
|
||||
direct PBS observation. Check the actual source and expected readings after
|
||||
adding the connection; the presence of a badge alone is not collection proof.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,42 +46,65 @@ user merely because no local administrator is configured.
|
||||
|
||||
## Managing Roles
|
||||
|
||||
### Before Using the API
|
||||
|
||||
Prefer **Settings → Security → Access Control** for one-off administration.
|
||||
The API examples require the licensed `rbac` capability and permission to
|
||||
administer users. With RBAC enabled, role and user administration requires a
|
||||
full-access (`*`) API token bound to an authorised administrator; a monitoring
|
||||
token is not enough. Do not widen an agent's token for this job.
|
||||
|
||||
If you already use an administration token, prepare its private header file
|
||||
as described in [API authentication](API.md#-authentication). The examples read
|
||||
`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into
|
||||
a command. Keep this file outside shared repositories and diagnostics.
|
||||
|
||||
The loopback URLs work on the Pulse host. For remote use, substitute your
|
||||
Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or
|
||||
later; `--disable` must remain first to ignore local trace/verbose defaults,
|
||||
and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each
|
||||
change separately and check its response before continuing.
|
||||
|
||||
### Creating a Role
|
||||
|
||||
**UI:** Settings → Security → Access Control → Create Role
|
||||
|
||||
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
|
||||
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7655/api/admin/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "operator",
|
||||
"name": "Operator",
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request POST \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON'
|
||||
{
|
||||
"id": "alert-manager",
|
||||
"name": "Alert Manager",
|
||||
"description": "Can view and manage alerts",
|
||||
"permissions": [
|
||||
{"action": "read", "resource": "alerts"},
|
||||
{"action": "write", "resource": "alerts"},
|
||||
{"action": "read", "resource": "nodes"}
|
||||
]
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Listing Roles
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/admin/roles \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/admin/roles
|
||||
```
|
||||
|
||||
### Updating a Role
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/roles/operator \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Operator",
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON'
|
||||
{
|
||||
"name": "Alert Manager",
|
||||
"description": "Updated description",
|
||||
"permissions": [
|
||||
{"action": "read", "resource": "alerts"},
|
||||
@@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
|
||||
{"action": "read", "resource": "nodes"},
|
||||
{"action": "read", "resource": "ai"}
|
||||
]
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Deleting a Role
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
|
||||
```
|
||||
|
||||
---
|
||||
@@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
|
||||
### Listing Users and Their Roles
|
||||
|
||||
```bash
|
||||
curl http://localhost:7655/api/admin/users \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/admin/users
|
||||
```
|
||||
|
||||
SSO users are displayed using the latest configured username claim and email
|
||||
@@ -118,20 +142,27 @@ principal used for authorization.
|
||||
|
||||
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
|
||||
|
||||
Use the stable `username` returned by the user list, not the display name or
|
||||
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
|
||||
SSO principal). `jane` below is an example local username. Create the custom
|
||||
role before assigning it; do not run the deletion example first.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"roleIds": ["operator", "viewer"]}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
|
||||
{"roleIds": ["alert-manager", "viewer"]}
|
||||
JSON
|
||||
```
|
||||
|
||||
To remove all custom roles from a user, send an empty list:
|
||||
To clear a user's entire role assignment, including built-in roles, send an empty list:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"roleIds": []}'
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' --request PUT \
|
||||
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
|
||||
{"roleIds": []}
|
||||
JSON
|
||||
```
|
||||
|
||||
Note: Users cannot modify their own role assignments (self-escalation prevention).
|
||||
@@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
|
||||
### Removing User Access
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7655/api/admin/users/jane \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
|
||||
```
|
||||
|
||||
This removes the Pulse identity and all role assignments and revokes its active
|
||||
|
||||
@@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the
|
||||
|
||||
## Recommended: Pulse Agent (Proxmox)
|
||||
|
||||
The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed.
|
||||
The unified agent runs on each Proxmox host and reports temperatures locally
|
||||
with no SSH keys needed. First complete the
|
||||
[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas)
|
||||
on that host: protect the Pulse agent token, download the installer from your
|
||||
Pulse server over verified HTTPS, and inspect it. Then use the same Pulse
|
||||
address and add the Proxmox collector flag:
|
||||
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox
|
||||
```
|
||||
|
||||
The Pulse agent token is not a Proxmox API token. Keep the secret out of
|
||||
command arguments and leave command execution disabled for monitoring.
|
||||
|
||||
Notes:
|
||||
- Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`).
|
||||
- Temperatures appear automatically once the agent reports.
|
||||
@@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
|
||||
|
||||
## Legacy Cleanup (If Upgrading)
|
||||
|
||||
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
|
||||
If you still have the old sensor proxy installed from prior releases, remove it
|
||||
from each **Proxmox host** (not the Pulse container) with the supported cleanup
|
||||
helper. In that host's administrative shell, download it to a private directory:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
|
||||
sudo bash -s -- --uninstall --purge --local-only
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
|
||||
--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh
|
||||
```
|
||||
|
||||
Stop if the download fails and inspect the saved script before running it.
|
||||
Do not pipe a web response into a privileged shell:
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
--uninstall --purge --local-only
|
||||
```
|
||||
|
||||
`--local-only` avoids cluster SSH entirely; run the command once on every
|
||||
@@ -122,8 +145,8 @@ remote portion fail after local cleanup completes.
|
||||
If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
|
||||
sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only
|
||||
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
|
||||
--uninstall --purge --remove-proxmox-access --local-only
|
||||
```
|
||||
|
||||
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.
|
||||
|
||||
@@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Generate an installation command in the UI:
|
||||
Choose the host profile and create a monitoring token in the UI:
|
||||
**Settings → Infrastructure → Install on a host**
|
||||
|
||||
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
|
||||
|
||||
The generated command is not tied to a single machine. For a Proxmox VE
|
||||
cluster, one API connection already provides cluster-wide inventory; the agent
|
||||
is per host, so run the same generated command on each cluster node where you
|
||||
want agent-provided telemetry (temperatures, SMART, host identity). Each agent
|
||||
registers itself and attaches to its own cluster member.
|
||||
Keep the token out of shell commands, history, URLs and screenshots. Use the
|
||||
private-file installation steps below; the command arguments contain only the
|
||||
file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring
|
||||
does not require command execution: leave that option off unless you intend
|
||||
to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root
|
||||
host agent instead of an API connection or the opt-in Linux safe profile.
|
||||
|
||||
The same generated command is also the supported v5-to-v6 agent upgrade path.
|
||||
For a Proxmox VE cluster, one API connection already provides cluster-wide
|
||||
inventory; the agent is per host. Repeat the private-file installation on each
|
||||
cluster node where you want agent-provided telemetry (temperatures, SMART, host
|
||||
identity), using the intended profile. Each agent registers itself and attaches
|
||||
to its own cluster member.
|
||||
|
||||
The same installer is also the supported v5-to-v6 agent upgrade path.
|
||||
Run it on the host that already has the v5 `pulse-agent` service to replace the
|
||||
binary and service configuration in place; do not uninstall the old service
|
||||
first unless you are intentionally removing that host from Pulse.
|
||||
@@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL
|
||||
so replacing the Pulse host does not require an agent migration.
|
||||
|
||||
After importing the configuration on a Pulse server with a different address,
|
||||
retarget each existing standard Linux agent from that agent machine:
|
||||
retarget each existing standard Linux agent from that agent machine. Download
|
||||
and inspect the installer from the **new** Pulse address using the HTTPS
|
||||
preparation below; retargeting reuses the saved credential, so do not create
|
||||
another token just for this operation:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pulse.example.com:7655/install.sh | \
|
||||
sudo bash -s -- --retarget --url https://pulse.example.com:7655
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--retarget --url https://pulse.example.com
|
||||
```
|
||||
|
||||
The retarget operation recovers the existing token, agent ID, enabled
|
||||
collectors, and other service options. It does not carry the old endpoint's
|
||||
TLS bypass, custom CA, or certificate fingerprint to the new address. Supply
|
||||
`--cacert`, `--server-fingerprint`, or (only on a trusted network)
|
||||
`--insecure` explicitly when the new endpoint requires it. The script must
|
||||
come from the new server so it supports the retarget operation. A newly
|
||||
generated full installation command from **Settings → Infrastructure → Install
|
||||
on a host** remains the fallback.
|
||||
`--cacert` or an independently verified `--server-fingerprint` explicitly
|
||||
when the new agent endpoint requires it. The installer download itself must
|
||||
use a trusted certificate or a separately verified CA file; an agent
|
||||
fingerprint option does not verify that earlier download. The script must
|
||||
come from the new server so it supports retargeting. If retargeting is not
|
||||
supported on that host, use the private-file installation below with the
|
||||
intended profile, rather than uninstalling the existing service first.
|
||||
|
||||
On Windows, run the full generated PowerShell installation command from the
|
||||
new Pulse server as Administrator, including the desired collector options.
|
||||
On Windows, use the PowerShell private-file installation below from the new
|
||||
Pulse server as Administrator, including the desired collector options.
|
||||
Do not expect the configuration import itself to make agent-only machines
|
||||
appear at the new address.
|
||||
|
||||
@@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with
|
||||
This is the agent installer served by your Pulse server. It is separate from the
|
||||
top-level GitHub `install.sh`, which installs or updates the Pulse server itself.
|
||||
|
||||
### Linux (systemd)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
### Private-file installation: Linux, macOS and NAS
|
||||
|
||||
### macOS
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
Use an administrative shell **on the host being monitored**, not the Pulse
|
||||
server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS
|
||||
use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API
|
||||
connection is usually enough for TrueNAS inventory and usage; an agent is
|
||||
optional for host-local data.
|
||||
|
||||
1. In that shell, protect the file before opening the editor. Save only the
|
||||
Pulse agent token, with no quotes or header. Use your own private directory
|
||||
and regular files, not shared paths or symlinks:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/agent-token"
|
||||
vi "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address
|
||||
in both the download and installation commands. Download to a file:
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
|
||||
--output "$HOME/.config/pulse/agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
```
|
||||
|
||||
**Stop if the download fails.** Inspect the saved script before executing
|
||||
it. Do not bypass certificate verification or pipe an unchecked response
|
||||
into a privileged shell. For a private CA, add curl's `--cacert` with the
|
||||
separately verified CA file, and supply the installer's `--cacert` option
|
||||
for the agent connection as well. Do not substitute GitHub's top-level
|
||||
`install.sh`: that installs the Pulse server, not the agent.
|
||||
|
||||
3. Install with the private token file, adding a profile from
|
||||
[Installation Options](#installation-options) when needed:
|
||||
|
||||
```bash
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
Keep the bootstrap token file private and remove that copy when no longer
|
||||
needed; do not delete the installed agent's runtime credential. Check a fresh
|
||||
report in Pulse and `pulse-agent --version` on the host. A started service or
|
||||
hardware capacity alone does not prove that every requested metric is present.
|
||||
|
||||
### Windows (PowerShell, run as Administrator)
|
||||
|
||||
Use a **new** setup directory under your own Windows profile; if it already
|
||||
exists, choose another name. The following preparation grants access only to
|
||||
your account and SYSTEM. Stop if directory creation or the ACL command fails:
|
||||
|
||||
```powershell
|
||||
irm http://<pulse-ip>:7655/install.ps1 | iex
|
||||
$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup'
|
||||
New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null
|
||||
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' }
|
||||
$tokenFile = Join-Path $setupDir 'agent-token.txt'
|
||||
New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null
|
||||
Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait
|
||||
```
|
||||
|
||||
With environment variables:
|
||||
In Notepad, save only the Pulse agent token, with no quotes or header. Never
|
||||
paste it into a PowerShell command or assign a literal secret to an environment
|
||||
variable. Replace the example HTTPS address in both commands below. Download
|
||||
and inspect the script first; do not use `Invoke-Expression` on a web response:
|
||||
|
||||
```powershell
|
||||
$env:PULSE_URL="http://<pulse-ip>:7655"
|
||||
$env:PULSE_TOKEN="<api-token>"
|
||||
irm http://<pulse-ip>:7655/install.ps1 | iex
|
||||
$installerFile = Join-Path $setupDir 'install.ps1'
|
||||
Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop
|
||||
```
|
||||
|
||||
### Synology NAS
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
Only after a successful download and inspection, run the saved script. Clear
|
||||
an old `PULSE_TOKEN` environment value so it cannot override the file:
|
||||
|
||||
```powershell
|
||||
Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue
|
||||
& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile
|
||||
```
|
||||
|
||||
### TrueNAS SCALE/CORE
|
||||
TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE).
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
|
||||
```
|
||||
Keep the setup directory private and remove the bootstrap copy when no longer
|
||||
needed, not the installed service's token. Verify a fresh report and the
|
||||
installed agent version; the remaining platform trust trade-offs are in
|
||||
[Agent Security](AGENT_SECURITY.md).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing.
|
||||
|
||||
## Installation Options
|
||||
|
||||
These commands use the protected token file and successfully downloaded,
|
||||
inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas).
|
||||
Use the same HTTPS address in preparation and installation. The flags below
|
||||
choose collectors; they do not grant command execution.
|
||||
|
||||
### Simple Install (host + Docker auto-detect)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token>
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
### Proxmox VE Node (explicit profile)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pve
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve
|
||||
```
|
||||
|
||||
### Proxmox Backup Server Node (explicit profile)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pbs
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs
|
||||
```
|
||||
|
||||
### Force Enable Docker (if auto-detection fails)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-docker
|
||||
```
|
||||
|
||||
### Disable Docker (even if detected)
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-docker=false
|
||||
```
|
||||
|
||||
### Host + Kubernetes Monitoring
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes
|
||||
```
|
||||
|
||||
### Docker Monitoring Only
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker
|
||||
```
|
||||
|
||||
### Exclude Specific Disks from Monitoring
|
||||
@@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop.
|
||||
|
||||
To disable auto-updates:
|
||||
```bash
|
||||
# During installation
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
|
||||
# After private-file preparation and successful installer inspection
|
||||
bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token" --disable-auto-update
|
||||
|
||||
# Or set environment variable
|
||||
PULSE_DISABLE_AUTO_UPDATE=true
|
||||
@@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys
|
||||
|
||||
## Uninstall
|
||||
|
||||
Download and inspect the agent installer using the HTTPS preparation above
|
||||
before running this on the agent host. Uninstallation needs no new token:
|
||||
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | bash -s -- --uninstall
|
||||
bash "$HOME/.config/pulse/agent-install.sh" --uninstall
|
||||
```
|
||||
|
||||
This removes:
|
||||
@@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged
|
||||
and installed copy must fit at once.
|
||||
|
||||
If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a
|
||||
directory on a data volume and re-run the installer:
|
||||
directory on a data volume and re-run the already downloaded and inspected
|
||||
agent installer from the private-file preparation above:
|
||||
|
||||
```bash
|
||||
TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token <token>
|
||||
TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \
|
||||
--url https://pulse.example.com \
|
||||
--token-file "$HOME/.config/pulse/agent-token"
|
||||
```
|
||||
|
||||
(`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root.
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('NodesAPI — branch coverage (normalizeProxmoxSetupCommandResponse val
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
@@ -149,10 +149,11 @@ describe('NodesAPI — branch coverage (downloadProxmoxSetupScript failure arms)
|
||||
});
|
||||
|
||||
const pveBootstrap = (): ProxmoxSetupCommandResponse => ({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
|
||||
@@ -313,8 +313,7 @@ describe('NodesAPI', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ command: 'curl ...' });
|
||||
expect(result).not.toHaveProperty('token');
|
||||
expect(result).toEqual({ command: 'curl ...', token: 'secret-token' });
|
||||
});
|
||||
|
||||
it('supports the PBS proxmox install command contract', async () => {
|
||||
@@ -332,6 +331,16 @@ describe('NodesAPI', () => {
|
||||
expect(result.command).toBe('curl pbs ...');
|
||||
});
|
||||
|
||||
it('rejects an agent credential embedded in the copied command', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
command: 'bash install.sh --token synthetic-secret',
|
||||
token: 'synthetic-secret',
|
||||
});
|
||||
await expect(
|
||||
NodesAPI.getAgentInstallCommand({ type: 'pve', enableProxmox: true }),
|
||||
).rejects.toThrow('Install credentials must be entered separately from the command');
|
||||
});
|
||||
|
||||
it('rejects blank install commands', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({ command: ' ', token: 'secret-token' });
|
||||
|
||||
@@ -342,12 +351,12 @@ describe('NodesAPI', () => {
|
||||
});
|
||||
|
||||
describe('getProxmoxSetupCommand', () => {
|
||||
it('uses the canonical setup-script-url contract for PVE while keeping raw setup tokens inside the shared client boundary', async () => {
|
||||
it('uses the canonical PVE setup contract with a separate revealed setup token', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
setupToken: 'setup-token-123',
|
||||
@@ -374,21 +383,41 @@ describe('NodesAPI', () => {
|
||||
);
|
||||
expect(result.type).toBe('pve');
|
||||
expect(result.host).toBe('https://pve.example:8006');
|
||||
expect(result.downloadURL).toBe(
|
||||
'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
);
|
||||
expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pve');
|
||||
expect(result.scriptFileName).toBe('pulse-setup-pve.sh');
|
||||
expect(result.tokenHint).toBe('set…123');
|
||||
expect(result.expires).toBe(1_900_000_000);
|
||||
expect(result).not.toHaveProperty('setupToken');
|
||||
expect(result.setupToken).toMatch(/token-123$/);
|
||||
});
|
||||
|
||||
it.each(['url', 'downloadURL', 'command', 'commandWithEnv', 'commandWithoutEnv'])(
|
||||
'rejects a setup credential embedded in %s without returning it in the error',
|
||||
async (field) => {
|
||||
const secret = 'synthetic-setup-secret';
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'safe command',
|
||||
setupToken: secret,
|
||||
tokenHint: 'syn…ret',
|
||||
expires: 1_900_000_000,
|
||||
[field]: `unsafe-${secret}`,
|
||||
});
|
||||
await expect(
|
||||
NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example', backupPerms: false }),
|
||||
).rejects.toThrow('Setup credentials must be entered separately from commands and URLs');
|
||||
},
|
||||
);
|
||||
|
||||
it('sends the typed connection name so the setup token can carry it into auto-registration', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
setupToken: 'setup-token-123',
|
||||
@@ -422,8 +451,7 @@ describe('NodesAPI', () => {
|
||||
type: ' pve ',
|
||||
host: ' https://pve.example:8006 ',
|
||||
url: ' https://pulse.example/api/setup-script?type=pve ',
|
||||
downloadURL:
|
||||
' https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123 ',
|
||||
downloadURL: ' https://pulse.example/api/setup-script?type=pve ',
|
||||
scriptFileName: ' pulse-setup-pve.sh ',
|
||||
command: ' curl pve ... ',
|
||||
commandWithEnv: ' curl env pve ... ',
|
||||
@@ -443,13 +471,14 @@ describe('NodesAPI', () => {
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
commandWithoutEnv: 'curl bare pve ...',
|
||||
expires: 1_900_000_000,
|
||||
tokenHint: 'set…123',
|
||||
setupToken: 'setup-token-123',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,7 +487,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pbs',
|
||||
host: 'https://pbs.example:8007',
|
||||
url: 'https://pulse.example/api/setup-script?type=pbs',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
|
||||
scriptFileName: 'pulse-setup-pbs.sh',
|
||||
command: 'curl pbs ...',
|
||||
setupToken: 'pbs-token-123',
|
||||
@@ -486,12 +515,10 @@ describe('NodesAPI', () => {
|
||||
expect(result.command).toBe('curl pbs ...');
|
||||
expect(result.type).toBe('pbs');
|
||||
expect(result.host).toBe('https://pbs.example:8007');
|
||||
expect(result.downloadURL).toBe(
|
||||
'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
);
|
||||
expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pbs');
|
||||
expect(result.scriptFileName).toBe('pulse-setup-pbs.sh');
|
||||
expect(result.tokenHint).toBe('pbs…123');
|
||||
expect(result).not.toHaveProperty('setupToken');
|
||||
expect(result.setupToken).toMatch(/token-123$/);
|
||||
});
|
||||
|
||||
it('falls back to command for commandWithEnv when the backend omits it', async () => {
|
||||
@@ -499,7 +526,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pbs',
|
||||
host: 'https://pbs.example:8007',
|
||||
url: 'https://pulse.example/api/setup-script?type=pbs',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
|
||||
scriptFileName: 'pulse-setup-pbs.sh',
|
||||
command: 'curl pbs ...',
|
||||
setupToken: 'pbs-token-123',
|
||||
@@ -521,7 +548,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pbs',
|
||||
host: 'https://pbs.example:8007',
|
||||
url: '',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
|
||||
scriptFileName: 'pulse-setup-pbs.sh',
|
||||
command: 'curl pbs ...',
|
||||
setupToken: 'pbs-token-123',
|
||||
@@ -543,7 +570,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
setupToken: 'setup-token-123',
|
||||
@@ -565,7 +592,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
command: 'curl pve ...',
|
||||
setupToken: 'setup-token-123',
|
||||
tokenHint: 'set…123',
|
||||
@@ -586,7 +613,7 @@ describe('NodesAPI', () => {
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/api/setup-script?type=pve',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
setupToken: 'setup-token-123',
|
||||
@@ -616,11 +643,11 @@ describe('NodesAPI', () => {
|
||||
);
|
||||
|
||||
const result = await NodesAPI.downloadProxmoxSetupScript({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
downloadURL:
|
||||
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
@@ -629,9 +656,7 @@ describe('NodesAPI', () => {
|
||||
tokenHint: 'set…123',
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pve');
|
||||
expect(result).toEqual({
|
||||
content: '#!/bin/bash\necho pve',
|
||||
contentType: 'text/x-shellscript; charset=utf-8',
|
||||
@@ -651,11 +676,11 @@ describe('NodesAPI', () => {
|
||||
);
|
||||
|
||||
const result = await NodesAPI.downloadProxmoxSetupScript({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pbs',
|
||||
host: 'pbs.example',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pbs',
|
||||
downloadURL:
|
||||
'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pbs',
|
||||
scriptFileName: 'pulse-setup-pbs.sh',
|
||||
command: 'curl pbs ...',
|
||||
commandWithEnv: 'curl env pbs ...',
|
||||
@@ -664,9 +689,7 @@ describe('NodesAPI', () => {
|
||||
tokenHint: 'pbs…123',
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123',
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pbs');
|
||||
expect(result).toEqual({
|
||||
content: '#!/bin/bash\necho pbs',
|
||||
contentType: 'text/x-shellscript; charset=utf-8',
|
||||
@@ -687,11 +710,11 @@ describe('NodesAPI', () => {
|
||||
|
||||
await expect(
|
||||
NodesAPI.downloadProxmoxSetupScript({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
downloadURL:
|
||||
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
@@ -712,11 +735,11 @@ describe('NodesAPI', () => {
|
||||
|
||||
await expect(
|
||||
NodesAPI.downloadProxmoxSetupScript({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
downloadURL:
|
||||
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
@@ -740,11 +763,11 @@ describe('NodesAPI', () => {
|
||||
|
||||
await expect(
|
||||
NodesAPI.downloadProxmoxSetupScript({
|
||||
setupToken: 'synthetic-setup-token',
|
||||
type: 'pve',
|
||||
host: 'https://pve.example:8006',
|
||||
url: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
downloadURL:
|
||||
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
|
||||
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
|
||||
scriptFileName: 'pulse-setup-pve.sh',
|
||||
command: 'curl pve ...',
|
||||
commandWithEnv: 'curl env pve ...',
|
||||
|
||||
@@ -59,6 +59,7 @@ type RawAgentInstallCommandResponse = {
|
||||
|
||||
export type AgentInstallCommandResponse = {
|
||||
command: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type RawHostAgentInstallTokenResponse = {
|
||||
@@ -96,6 +97,7 @@ export type ProxmoxSetupCommandResponse = {
|
||||
commandWithoutEnv?: string;
|
||||
expires: number;
|
||||
tokenHint: string;
|
||||
setupToken: string;
|
||||
};
|
||||
|
||||
export type DownloadedProxmoxSetupScript = {
|
||||
@@ -112,7 +114,11 @@ const normalizeAgentInstallCommandResponse = (
|
||||
throw new Error('Invalid agent install command response');
|
||||
}
|
||||
|
||||
return { command };
|
||||
const token = optionalTrimmedString(response.token);
|
||||
if (token && command.includes(token)) {
|
||||
throw new Error('Install credentials must be entered separately from the command');
|
||||
}
|
||||
return { command, ...(token ? { token } : {}) };
|
||||
};
|
||||
|
||||
const normalizeProxmoxSetupCommandResponse = (
|
||||
@@ -156,6 +162,14 @@ const normalizeProxmoxSetupCommandResponse = (
|
||||
if (!setupToken) {
|
||||
throw new Error('Invalid Proxmox setup response setup token');
|
||||
}
|
||||
if (
|
||||
[url, downloadURL, command, commandWithEnv, commandWithoutEnv ?? ''].some((value) =>
|
||||
value.includes(setupToken),
|
||||
) ||
|
||||
/[?&]setup_token=/.test(downloadURL)
|
||||
) {
|
||||
throw new Error('Setup credentials must be entered separately from commands and URLs');
|
||||
}
|
||||
if (!tokenHint) {
|
||||
throw new Error('Invalid Proxmox setup response token hint');
|
||||
}
|
||||
@@ -174,6 +188,7 @@ const normalizeProxmoxSetupCommandResponse = (
|
||||
commandWithoutEnv,
|
||||
expires,
|
||||
tokenHint,
|
||||
setupToken,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
describe('ResourceDetailDrawer change history section', () => {
|
||||
it('keeps current readings separate from stored history samples', () => {
|
||||
expect(guestDrawerHistorySource).toContain('currentMetrics');
|
||||
expect(guestDrawerHistorySource).toContain('Collecting history');
|
||||
expect(guestDrawerHistorySource).toContain('No stored history in this range');
|
||||
expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints');
|
||||
});
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-[11px] text-muted italic">
|
||||
No token fields are needed here. The node appears in Pulse automatically after
|
||||
the agent starts.
|
||||
Run the command first, then paste the separately revealed token at its silent
|
||||
prompt. The node appears in Pulse after the agent starts.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -286,7 +286,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
await state.copyQuickSetupCommand(
|
||||
'pve',
|
||||
true,
|
||||
'Command copied to clipboard! Run it on the server. The one-time setup token is already embedded.',
|
||||
'Command copied to clipboard! Run it on the server. Paste the separate token only at the silent prompt.',
|
||||
);
|
||||
}}
|
||||
class="absolute top-2 right-2 p-1.5 text-slate-400 hover:text-slate-200 bg-surface-hover rounded-md transition-colors"
|
||||
@@ -318,10 +318,11 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
}
|
||||
>
|
||||
<div class="pr-10 font-sans text-left">
|
||||
<p class="font-medium text-base-content">Credentialed command ready</p>
|
||||
<p class="font-medium text-base-content">Credential-free command ready</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
Use Copy command to place the runnable command on your clipboard. The
|
||||
one-time setup token is intentionally not shown on this page.
|
||||
one-time setup token is shown separately. Run the command first, then
|
||||
paste the token only at its silent prompt, never into a shell command.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -424,7 +425,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
|
||||
Fully automatic: no manual token copying needed.
|
||||
The monitoring API token is registered automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,8 +729,8 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-muted">
|
||||
No token fields are needed here. The server appears in Pulse automatically after
|
||||
the agent connects.
|
||||
Run the command first, then paste the separately revealed token at its silent
|
||||
prompt. The server appears in Pulse after the agent connects.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -757,7 +758,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
await state.copyQuickSetupCommand(
|
||||
'pbs',
|
||||
false,
|
||||
'Command copied to clipboard! Run it on the server. The one-time setup token is already embedded.',
|
||||
'Command copied to clipboard! Run it on the server. Paste the separate token only at the silent prompt.',
|
||||
);
|
||||
}}
|
||||
class="absolute top-2 right-2 p-1.5 text-slate-400 hover:text-slate-200 bg-surface-hover rounded-md transition-colors"
|
||||
@@ -790,10 +791,11 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
}
|
||||
>
|
||||
<div class="pr-10 font-sans text-left">
|
||||
<p class="font-medium text-base-content">Credentialed command ready</p>
|
||||
<p class="font-medium text-base-content">Credential-free command ready</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
Use Copy command to place the runnable command on your clipboard. The
|
||||
one-time setup token is intentionally not shown on this page.
|
||||
one-time setup token is shown separately. Run the command first, then
|
||||
paste the token only at its silent prompt, never into a shell command.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -895,7 +897,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
|
||||
Fully automatic: no manual token copying needed.
|
||||
The monitoring API token is registered automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-4
@@ -90,13 +90,11 @@ describe('NodeModalSetupGuideSection', () => {
|
||||
quickSetupTokenHint: 'set…123',
|
||||
});
|
||||
|
||||
expect(screen.getByText('Credentialed command ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credential-free command ready')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Use Copy command to place the runnable command on your clipboard/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/one-time setup token is intentionally not shown/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/one-time setup token is shown separately/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/curl -fsSL/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('set…123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1813,11 +1813,10 @@ describe('settings architecture guardrails', () => {
|
||||
);
|
||||
expect(nodeModalSetupGuideSectionSource).toContain('setupCommandButtonTitle');
|
||||
expect(nodeModalSetupGuideSectionSource).toContain('disabled={setupHandoffDisabled()}');
|
||||
expect(nodeModalSetupGuideSectionSource).toContain('Credentialed command ready');
|
||||
expect(nodeModalSetupGuideSectionSource).toContain(
|
||||
'one-time setup token is intentionally not shown on this page',
|
||||
);
|
||||
expect(nodeModalStateSource).toContain('data.commandWithEnv');
|
||||
expect(nodeModalSetupGuideSectionSource).toContain('Credential-free command ready');
|
||||
expect(nodeModalSetupGuideSectionSource).toContain('one-time setup token is shown separately');
|
||||
expect(nodeModalStateSource).toContain('copyToClipboard(data.command)');
|
||||
expect(nodeModalStateSource).toContain('showSetupToken(data)');
|
||||
expect(nodeModalStateSource).not.toContain('quickSetupPreviewCommand');
|
||||
expect(nodeModalStatusFooterSource).toContain('guidedSetupOnlyMode');
|
||||
expect(nodeModalStatusFooterSource).toContain('props.saveDisabled');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
@@ -6,6 +6,7 @@ import { NodesAPI } from '@/api/nodes';
|
||||
import type { ProxmoxSetupCommandResponse } from '@/api/nodes';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||
import {
|
||||
buildNodeModalMonitoringPayload,
|
||||
getNodeModalDefaultFormData,
|
||||
@@ -71,6 +72,14 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
setQuickSetupExpiry(null);
|
||||
};
|
||||
|
||||
let bootstrapGeneration = 0;
|
||||
const bootstrapActive = (generation: number) =>
|
||||
generation === bootstrapGeneration && props.isOpen !== false;
|
||||
onCleanup(() => {
|
||||
bootstrapGeneration++;
|
||||
clearQuickSetupState();
|
||||
});
|
||||
|
||||
const copyCommand = async (command: string, successMessage = 'Command copied!') => {
|
||||
if (await copyToClipboard(command)) {
|
||||
notificationStore.success(successMessage);
|
||||
@@ -78,6 +87,7 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
};
|
||||
|
||||
const copyProxmoxAgentInstallCommand = async (type: 'pve' | 'pbs', successMessage: string) => {
|
||||
const generation = bootstrapGeneration;
|
||||
try {
|
||||
setLoadingAgentCommand(true);
|
||||
setAgentCommandError(null);
|
||||
@@ -87,7 +97,15 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
enableCommands: false,
|
||||
insecure: agentInstallInsecure(),
|
||||
});
|
||||
if (!bootstrapActive(generation)) return;
|
||||
setAgentInstallCommand(data.command);
|
||||
if (data.token) {
|
||||
showTokenReveal({
|
||||
token: data.token,
|
||||
source: type,
|
||||
note: 'Run the copied install command first. Then copy this token and paste it only at the silent “Pulse agent token” prompt. It is not part of the command.',
|
||||
});
|
||||
}
|
||||
const copied = await copyToClipboard(data.command);
|
||||
if (copied) {
|
||||
notificationStore.success(successMessage);
|
||||
@@ -98,12 +116,13 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
setAgentCommandError(copyFailureMessage);
|
||||
notificationStore.error(copyFailureMessage);
|
||||
} catch (error) {
|
||||
if (!bootstrapActive(generation)) return;
|
||||
logger.error('[Host Telemetry Agent] Error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to generate install command';
|
||||
setAgentCommandError(message);
|
||||
notificationStore.error(message);
|
||||
} finally {
|
||||
setLoadingAgentCommand(false);
|
||||
if (bootstrapActive(generation)) setLoadingAgentCommand(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +132,8 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
const loadQuickSetupBootstrap = async (
|
||||
type: 'pve' | 'pbs',
|
||||
backupPerms: boolean,
|
||||
): Promise<ProxmoxSetupCommandResponse> => {
|
||||
): Promise<ProxmoxSetupCommandResponse | null> => {
|
||||
const generation = bootstrapGeneration;
|
||||
const host = formData().host?.trim() ?? '';
|
||||
if (!host) {
|
||||
notificationStore.error('Please enter the Endpoint URL first');
|
||||
@@ -135,6 +155,8 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
backupPerms,
|
||||
name: formData().name?.trim() || undefined,
|
||||
});
|
||||
if (!bootstrapActive(generation) || quickSetupCacheKey(type, backupPerms) !== cacheKey)
|
||||
return null;
|
||||
setQuickSetupBootstrap({ cacheKey, response });
|
||||
setQuickSetupTokenHint(response.tokenHint);
|
||||
setQuickSetupExpiry(response.expires);
|
||||
@@ -146,18 +168,22 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
backupPerms: boolean,
|
||||
successMessage: string,
|
||||
) => {
|
||||
const generation = bootstrapGeneration;
|
||||
logger.debug('[Quick Setup] Copy button clicked');
|
||||
try {
|
||||
logger.debug('[Quick Setup] Generating setup URL for host', {
|
||||
host: formData().host,
|
||||
});
|
||||
const data = await loadQuickSetupBootstrap(type, backupPerms);
|
||||
if (await copyToClipboard(data.commandWithEnv)) {
|
||||
if (!data || !bootstrapActive(generation)) return;
|
||||
showSetupToken(data);
|
||||
if (await copyToClipboard(data.command)) {
|
||||
notificationStore.success(successMessage);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to copy to clipboard');
|
||||
} catch (error) {
|
||||
if (!bootstrapActive(generation)) return;
|
||||
logger.error('[Quick Setup] Error:', error);
|
||||
clearQuickSetupState();
|
||||
if (!(error instanceof Error && error.message === PROXMOX_SETUP_HOST_REQUIRED_MESSAGE)) {
|
||||
@@ -166,12 +192,24 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const showSetupToken = (bootstrap: ProxmoxSetupCommandResponse) => {
|
||||
showTokenReveal({
|
||||
token: bootstrap.setupToken,
|
||||
source: bootstrap.type,
|
||||
note: `Run the copied setup command (or downloaded script) first. Then copy this token and paste it only at the silent “Pulse setup token” prompt. It expires at ${new Date(bootstrap.expires * 1000).toLocaleTimeString()}. Never paste it into a shell command.`,
|
||||
});
|
||||
};
|
||||
|
||||
const setupScriptRunHint = (fileName: string) => `bash ${fileName}`;
|
||||
|
||||
const downloadProxmoxSetupScript = async (type: 'pve' | 'pbs', backupPerms = false) => {
|
||||
const generation = bootstrapGeneration;
|
||||
try {
|
||||
const bootstrap = await loadQuickSetupBootstrap(type, backupPerms);
|
||||
if (!bootstrap || !bootstrapActive(generation)) return;
|
||||
const data = await NodesAPI.downloadProxmoxSetupScript(bootstrap);
|
||||
if (!bootstrapActive(generation)) return;
|
||||
showSetupToken(bootstrap);
|
||||
|
||||
const blob = new Blob([data.content], {
|
||||
type: data.contentType,
|
||||
@@ -189,6 +227,7 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
`Script downloaded! Upload it to your server and run: ${setupScriptRunHint(data.fileName)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!bootstrapActive(generation)) return;
|
||||
logger.error('Failed to download script:', error);
|
||||
notificationStore.error('Failed to download script. Please check your connection.');
|
||||
}
|
||||
@@ -215,6 +254,12 @@ export const useNodeModalState = (props: NodeModalProps) => {
|
||||
const editingNode = props.editingNode;
|
||||
const prefillNode = props.prefillNode;
|
||||
|
||||
if (isOpen === false || key !== previousResetKey || nodeType !== previousNodeType) {
|
||||
bootstrapGeneration++;
|
||||
clearQuickSetupState();
|
||||
setLoadingAgentCommand(false);
|
||||
}
|
||||
|
||||
if (key !== undefined && key !== previousResetKey) {
|
||||
previousResetKey = key;
|
||||
setFormData(() => getNodeModalDefaultFormData(props.nodeType));
|
||||
|
||||
@@ -556,8 +556,8 @@ describe('GuestDrawer', () => {
|
||||
await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalled());
|
||||
|
||||
const utilizationChart = screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
expect(utilizationChart).toHaveTextContent('CPU25.0%');
|
||||
expect(utilizationChart).toHaveTextContent('Collecting history');
|
||||
expect(utilizationChart).toHaveTextContent('CPU25.0%current');
|
||||
expect(utilizationChart).toHaveTextContent('No stored history in this range');
|
||||
expect(utilizationChart.querySelector('path')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -274,9 +274,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
|
||||
});
|
||||
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
|
||||
const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1));
|
||||
const singleObservation = createMemo(() =>
|
||||
observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null,
|
||||
);
|
||||
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
|
||||
const bounds = () => props.timeBounds;
|
||||
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
|
||||
const hasStoredData = createMemo(() => observationTimes().length > 0 && bounds() !== null);
|
||||
const hoveredSeries = createMemo(() => {
|
||||
const timestamp = activeTimestamp();
|
||||
const rangeBounds = bounds();
|
||||
@@ -309,6 +313,11 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
const currentValue = props.currentMetrics?.[item.metric];
|
||||
return {
|
||||
...item,
|
||||
isCurrent:
|
||||
activeTimestamp() === null &&
|
||||
item.points.length === 0 &&
|
||||
typeof currentValue === 'number' &&
|
||||
Number.isFinite(currentValue),
|
||||
valueLabel: hovered
|
||||
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
|
||||
: activeTimestamp() !== null
|
||||
@@ -369,12 +378,18 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
<div class="flex flex-wrap justify-end gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<For each={displaySeries()}>
|
||||
{(item) => (
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
data-history-current={item.isCurrent ? item.metric : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
|
||||
<circle cx="5" cy="5" r="4" fill={item.color} />
|
||||
</svg>
|
||||
<span class="font-medium text-base-content">{item.label}</span>
|
||||
<span>{item.valueLabel}</span>
|
||||
<Show when={item.isCurrent}>
|
||||
<span>current</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
@@ -421,26 +436,48 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
</For>
|
||||
<Show when={bounds()}>
|
||||
{(rangeBounds) => (
|
||||
<For each={drawableSeries()}>
|
||||
{(item) => (
|
||||
<path
|
||||
d={buildGuestDrawerHistoryPath(
|
||||
item.points,
|
||||
scale(),
|
||||
rangeBounds().startTime,
|
||||
rangeBounds().endTime,
|
||||
GUEST_DRAWER_HISTORY_CHART_WIDTH,
|
||||
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
|
||||
)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<>
|
||||
<For each={drawableSeries()}>
|
||||
{(item) => (
|
||||
<path
|
||||
d={buildGuestDrawerHistoryPath(
|
||||
item.points,
|
||||
scale(),
|
||||
rangeBounds().startTime,
|
||||
rangeBounds().endTime,
|
||||
GUEST_DRAWER_HISTORY_CHART_WIDTH,
|
||||
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
|
||||
)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<For each={singlePointSeries()}>
|
||||
{(item) => (
|
||||
<circle
|
||||
aria-hidden="true"
|
||||
data-history-observation={item.metric}
|
||||
cx={getGuestDrawerHistoryX(
|
||||
item.points[0].timestamp,
|
||||
rangeBounds().startTime,
|
||||
rangeBounds().endTime,
|
||||
)}
|
||||
cy={getGuestDrawerHistoryY(item.points[0].value, scale())}
|
||||
r="3.5"
|
||||
fill={item.color}
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
class="text-surface"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={hoverX() !== null && hoveredSeries().length > 0}>
|
||||
@@ -471,9 +508,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
</For>
|
||||
</Show>
|
||||
</svg>
|
||||
<Show when={!hasDrawableData() && !props.loading}>
|
||||
<Show when={!hasStoredData() && !props.loading}>
|
||||
<div class="absolute inset-x-8 inset-y-2 flex items-center justify-center rounded-sm bg-surface/80 text-xs text-muted">
|
||||
Collecting history
|
||||
No stored history in this range
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.loading}>
|
||||
@@ -515,6 +552,19 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
<Show when={!props.loading && singleObservation()}>
|
||||
{(observation) => (
|
||||
<p class="mt-2 text-[10px] text-muted">
|
||||
<span>Single observation. No trend yet.</span>
|
||||
<time
|
||||
class="block tabular-nums"
|
||||
dateTime={new Date(observation().timestamp).toISOString()}
|
||||
>
|
||||
{new Date(observation().timestamp).toLocaleString()}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={observationTimes().length > 1}>
|
||||
<div class="mt-2">
|
||||
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
|
||||
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await screen.findByText('Failed to load history data');
|
||||
const retry = screen.getByRole('button', { name: 'Retry history' });
|
||||
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
|
||||
expect(container).not.toHaveTextContent('private diagnostic detail');
|
||||
expect(paths(container)).toHaveLength(0);
|
||||
@@ -204,7 +204,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(screen.getByText('Failed to load history data')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
|
||||
expect(paths(container)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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, type HistoryTimeRange } from '@/api/charts';
|
||||
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
maxHistoryDays: () => 7,
|
||||
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const start = Date.UTC(2026, 9, 1, 11);
|
||||
const time = start + 30 * 60_000;
|
||||
const end = start + 60 * 60_000;
|
||||
const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' };
|
||||
const point = (timestamp = time, value = 0) => ({ timestamp, value, min: value, max: value });
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
...target,
|
||||
range: '1h',
|
||||
start,
|
||||
end,
|
||||
source: 'store',
|
||||
metrics,
|
||||
});
|
||||
const mount = (currentMetrics: Record<string, number | undefined> = {}) =>
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="1h"
|
||||
groups={HOST_METRICS_HISTORY_GROUPS}
|
||||
currentMetrics={currentMetrics}
|
||||
/>
|
||||
));
|
||||
const group = (id = 'utilization') =>
|
||||
screen
|
||||
.getAllByTestId('guest-history-group-chart')
|
||||
.find((chart) => chart.dataset.historyGroup === id)!;
|
||||
const dots = () => document.querySelectorAll('[data-history-observation]');
|
||||
|
||||
describe('GuestDrawerHistory sparse observations and current provenance', () => {
|
||||
it.each([
|
||||
['cpu', 'utilization', 'CPU 0.0%'],
|
||||
['netin', 'network', 'In 0 B/s'],
|
||||
['temperature', 'thermals', 'CPU 0°C'],
|
||||
])(
|
||||
'plots a lone measured zero for %s, without a synthetic trend or collecting claim',
|
||||
async (metric, id, label) => {
|
||||
const fetch = vi
|
||||
.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValue(response({ [metric]: [point()] }));
|
||||
mount({ [metric]: 99 });
|
||||
await waitFor(() => expect(dots()).toHaveLength(1));
|
||||
const chart = group(id);
|
||||
const dot = chart.querySelector(`[data-history-observation="${metric}"]`)!;
|
||||
expect(Number(dot.getAttribute('cx'))).toBeCloseTo(193);
|
||||
expect(Number(dot.getAttribute('cy'))).toBeCloseTo(74);
|
||||
expect(chart.querySelector('path')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-current]')).toBeNull();
|
||||
expect(chart).not.toHaveTextContent('No stored history in this range');
|
||||
expect(chart).toHaveTextContent('Single observation. No trend yet.');
|
||||
expect(chart.querySelector('time.block')).toHaveAttribute(
|
||||
'datetime',
|
||||
new Date(time).toISOString(),
|
||||
);
|
||||
expect(within(chart).getByRole('img')).toHaveAccessibleDescription(
|
||||
new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('draws a lone series alongside a trend and labels only the live fallback', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
cpu: [point(start, 10), point(end, 20)],
|
||||
memory: [point(time, 0)],
|
||||
}),
|
||||
);
|
||||
mount({ cpu: 99, memory: 98, disk: 88 });
|
||||
await waitFor(() => expect(dots()).toHaveLength(1));
|
||||
expect(group().querySelectorAll('path')).toHaveLength(1);
|
||||
expect(group().querySelector('[data-history-observation]')).toHaveAttribute(
|
||||
'data-history-observation',
|
||||
'memory',
|
||||
);
|
||||
expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1);
|
||||
expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'Disk88.0%current',
|
||||
);
|
||||
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
|
||||
const slider = within(group()).getByRole('slider');
|
||||
slider.focus();
|
||||
fireEvent.input(slider, { target: { value: '1' } });
|
||||
expect(group()).toHaveTextContent('CPU-');
|
||||
expect(group()).toHaveTextContent('Memory0.0%');
|
||||
expect(group()).toHaveTextContent('Disk-');
|
||||
expect(group().querySelector('[data-history-current]')).toBeNull();
|
||||
expect(dots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not connect single observations from different series or borrow their times', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
cpu: [point(start, 10)],
|
||||
memory: [point(end, 20)],
|
||||
}),
|
||||
);
|
||||
mount();
|
||||
await waitFor(() => expect(dots()).toHaveLength(2));
|
||||
expect(group().querySelector('path')).toBeNull();
|
||||
expect(group().querySelector('[data-history-observation="cpu"]')).toHaveAttribute('cx', '34');
|
||||
expect(group().querySelector('[data-history-observation="memory"]')).toHaveAttribute(
|
||||
'cx',
|
||||
'352',
|
||||
);
|
||||
const slider = within(group()).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 no observation.`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['empty', 'invalid'] as const)(
|
||||
'reports a successful %s read honestly and never plots current readings',
|
||||
async (kind) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response(
|
||||
kind === 'empty'
|
||||
? {}
|
||||
: {
|
||||
cpu: [point(NaN, 12)],
|
||||
memory: [point(time, Infinity)],
|
||||
},
|
||||
),
|
||||
);
|
||||
mount({ cpu: 0, memory: Infinity, disk: undefined });
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(4),
|
||||
);
|
||||
expect(group().querySelector('[data-history-current="cpu"]')).toHaveTextContent(
|
||||
'CPU0.0%current',
|
||||
);
|
||||
expect(document.querySelectorAll('[data-history-current]')).toHaveLength(1);
|
||||
expect(dots()).toHaveLength(0);
|
||||
expect(document.querySelectorAll('path')).toHaveLength(0);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('retains a lone observation after failed refresh and removes its marker and caption when a trend arrives', async () => {
|
||||
const fetch = vi
|
||||
.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
|
||||
.mockRejectedValueOnce(new Error('private diagnostic'))
|
||||
.mockResolvedValueOnce(response({ cpu: [point(time, 0), point(end, 5)] }));
|
||||
mount();
|
||||
await waitFor(() => expect(dots()).toHaveLength(1));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await screen.findByText('History refresh failed. Showing previously loaded history.');
|
||||
expect(dots()).toHaveLength(1);
|
||||
expect(group()).toHaveTextContent('Single observation. No trend yet.');
|
||||
expect(document.body).not.toHaveTextContent('private diagnostic');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
|
||||
await screen.findByRole('slider');
|
||||
expect(group().querySelectorAll('path')).toHaveLength(1);
|
||||
expect(dots()).toHaveLength(0);
|
||||
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(['target', 'range'] as const)(
|
||||
'clears former observations and captions during an uncached %s replacement',
|
||||
async (replacement) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
|
||||
.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const [current, setTarget] = createSignal(target);
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={current()}
|
||||
range={range()}
|
||||
groups={HOST_METRICS_HISTORY_GROUPS}
|
||||
/>
|
||||
));
|
||||
await waitFor(() => expect(dots()).toHaveLength(1));
|
||||
if (replacement === 'target') setTarget({ ...target, resourceId: 'other-host' });
|
||||
else setRange('6h');
|
||||
await waitFor(() => expect(screen.getAllByText('Loading history')).toHaveLength(4));
|
||||
expect(dots()).toHaveLength(0);
|
||||
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll('time')).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
+3
-1
@@ -177,7 +177,9 @@ describe('GuestDrawerHistory shared time window', () => {
|
||||
it('keeps an empty valid query window without inventing observations or a trend', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} }));
|
||||
mount();
|
||||
await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(4),
|
||||
);
|
||||
expect(screen.queryByRole('slider')).toBeNull();
|
||||
expect(document.querySelectorAll('path')).toHaveLength(0);
|
||||
expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4);
|
||||
|
||||
@@ -63,11 +63,17 @@ export function TableBody(props: TableBodyProps) {
|
||||
|
||||
export type TableRowProps = JSX.HTMLAttributes<HTMLTableRowElement>;
|
||||
|
||||
// WebKit touch needs a native click target on otherwise-static table rows.
|
||||
// Keep actions document-delegated: nested controls must run first and retain
|
||||
// their existing stopPropagation behaviour instead of opening the row too.
|
||||
const nativeRowClickTarget = () => undefined;
|
||||
|
||||
export function TableRow(props: TableRowProps) {
|
||||
const [local, rest] = splitProps(props, ['class', 'children']);
|
||||
return (
|
||||
<tr
|
||||
class={`group transition-colors duration-150 hover:bg-surface-hover ${local.class || ''}`}
|
||||
on:click={rest.onClick ? nativeRowClickTarget : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
|
||||
@@ -103,6 +103,23 @@ describe('PulseDataGrid', () => {
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('inherits the native touch click target from the shared TableRow', () => {
|
||||
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
||||
try {
|
||||
render(() => (
|
||||
<PulseDataGrid<TestRow>
|
||||
data={[{ id: '1', name: 'Touch tower' }]}
|
||||
columns={[{ key: 'name', label: 'Name' }]}
|
||||
keyExtractor={(row) => row.id}
|
||||
onRowClick={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
|
||||
} finally {
|
||||
nativeListener.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trigger the row handler when an interactive child is clicked', () => {
|
||||
const onRowClick = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import tableSource from '@/components/shared/Table.tsx?raw';
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,127 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TableRow touch activation', () => {
|
||||
it('marks clickable rows as native click targets without changing row semantics', () => {
|
||||
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
||||
const onClick = vi.fn();
|
||||
render(() => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow onClick={onClick}>
|
||||
<TableCell>Tap resource</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
|
||||
const row = screen.getByRole('row');
|
||||
expect(row).not.toHaveAttribute('tabindex');
|
||||
expect(row).not.toHaveAttribute('aria-expanded');
|
||||
fireEvent.click(screen.getByText('Tap resource'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
fireEvent.keyDown(row, { key: 'Enter' });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not make static rows native click targets', () => {
|
||||
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
||||
render(() => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Static resource</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets delegated child actions stop the row before its action runs', () => {
|
||||
const onClick = vi.fn();
|
||||
const onChild = vi.fn();
|
||||
render(() => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow onClick={onClick}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChild();
|
||||
}}
|
||||
>
|
||||
Child action
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Child action' }));
|
||||
expect(onChild).toHaveBeenCalledTimes(1);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the native target when the row action is withdrawn', () => {
|
||||
const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
|
||||
const onClick = vi.fn();
|
||||
const [enabled, setEnabled] = createSignal(true);
|
||||
render(() => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow onClick={enabled() ? onClick : undefined}>
|
||||
<TableCell>Changing resource</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
fireEvent.click(screen.getByText('Changing resource'));
|
||||
setEnabled(false);
|
||||
expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
|
||||
fireEvent.click(screen.getByText('Changing resource'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
setEnabled(true);
|
||||
fireEvent.click(screen.getByText('Changing resource'));
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('preserves bound delegated handlers and caller-owned native handlers', () => {
|
||||
const onClick = vi.fn();
|
||||
const onNative = vi.fn();
|
||||
render(() => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
on:click={(event) => {
|
||||
event.stopPropagation();
|
||||
onNative();
|
||||
}}
|
||||
>
|
||||
<TableCell>Native action</TableCell>
|
||||
</TableRow>
|
||||
<TableRow onClick={[onClick, 'second-identity']}>
|
||||
<TableCell>Bound action</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
fireEvent.click(screen.getByText('Native action'));
|
||||
expect(onNative).toHaveBeenCalledTimes(1);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText('Bound action'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onClick.mock.calls[0][0]).toBe('second-identity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TableBody', () => {
|
||||
it('keeps the shared table wrapper CSP-safe', () => {
|
||||
expect(tableSource).toContain('touch-scroll');
|
||||
|
||||
@@ -48,12 +48,24 @@ describe('tokenRevealStore', () => {
|
||||
const state = tokenRevealStore.state();
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.token).toBe('secret-token');
|
||||
expect(state!.record.id).toBe('token-1');
|
||||
expect(state!.record!.id).toBe('token-1');
|
||||
expect(state!.source).toBe('settings');
|
||||
expect(state!.note).toBe('Test note');
|
||||
expect(state!.issuedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('reveals a temporary setup token without inventing an API token record', () => {
|
||||
showTokenReveal({
|
||||
token: 'synthetic-setup-token',
|
||||
source: 'pve',
|
||||
note: 'Private input only',
|
||||
});
|
||||
expect(tokenRevealStore.state()?.token).toBe('synthetic-setup-token');
|
||||
expect(tokenRevealStore.state()?.record).toBeUndefined();
|
||||
dismissTokenReveal();
|
||||
expect(tokenRevealStore.state()).toBeNull();
|
||||
});
|
||||
|
||||
it('sets issuedAt to current timestamp', () => {
|
||||
const before = Date.now();
|
||||
showTokenReveal({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { APITokenRecord } from '@/api/security';
|
||||
|
||||
export interface TokenRevealPayload {
|
||||
token: string;
|
||||
record: APITokenRecord;
|
||||
record?: APITokenRecord;
|
||||
source?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -223,6 +223,17 @@ describe('docsLinks', () => {
|
||||
expect(apiReference).not.toContain('Mobile Remote Access');
|
||||
});
|
||||
|
||||
it('ships credential-free Proxmox setup instructions and a separate token prompt', () => {
|
||||
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
const shipped = readFileSync(path.join(frontendRoot, 'public', 'docs', 'API.md'), 'utf8');
|
||||
const setup = apiReference.split('### Setup Script URL')[1].split('### Auto-Register')[0];
|
||||
expect(shipped).toBe(apiReference);
|
||||
expect(setup).toContain('identical credential-free commands');
|
||||
expect(setup).toContain('`downloadURL` equals the tokenless `url`');
|
||||
expect(setup).toContain('paste the token only at its prompt');
|
||||
expect(setup).not.toContain('token-bearing `commandWithEnv`');
|
||||
});
|
||||
|
||||
it('ships the per-alert snooze and resume API contract', () => {
|
||||
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
const shippedAPIReference = readFileSync(
|
||||
|
||||
+39
-31
@@ -2150,7 +2150,7 @@ smoke_test_pve_auto_register_token() {
|
||||
local smoke_status=0
|
||||
|
||||
set +e
|
||||
smoke_output=$(curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes" 2>&1)
|
||||
smoke_output=$(printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes" 2>&1)
|
||||
smoke_status=$?
|
||||
set -e
|
||||
|
||||
@@ -2252,10 +2252,12 @@ print(json.dumps({"type": "pve", "host": host, "backupPerms": backup}))
|
||||
PY
|
||||
)
|
||||
|
||||
echo "$setup_payload" > /tmp/pulse-auto-register-request.json 2>/dev/null || true
|
||||
# Bootstrap request/response are not retained in shared /tmp diagnostics.
|
||||
|
||||
local pulse_url="http://${pulse_ip}:${pulse_port}"
|
||||
|
||||
set +xv
|
||||
|
||||
local setup_response
|
||||
if ! setup_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"); then
|
||||
AUTO_NODE_REGISTER_ERROR="setup token request failed"
|
||||
@@ -2263,8 +2265,7 @@ PY
|
||||
return
|
||||
fi
|
||||
|
||||
# Persist for debugging when running interactively
|
||||
echo "$setup_response" > /tmp/pulse-auto-register-response.json 2>/dev/null || true
|
||||
# The response contains a one-time credential; do not persist it as a diagnostic.
|
||||
|
||||
local setup_token
|
||||
local setup_type
|
||||
@@ -2278,17 +2279,18 @@ PY
|
||||
local setup_token_hint
|
||||
local setup_expires
|
||||
local setup_expiry_state
|
||||
IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$setup_response" "$pulse_url" "$normalized_host_url" <<'PY'
|
||||
IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$pulse_url" "$normalized_host_url" "$backup_perms" 3<<<"$setup_response" <<'PY'
|
||||
import json, sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
try:
|
||||
data = json.loads(sys.argv[1])
|
||||
data = json.load(__import__("os").fdopen(3))
|
||||
except Exception:
|
||||
print("\t\t\t\t\t\t\t\t\t\t")
|
||||
sys.exit(0)
|
||||
pulse_url = sys.argv[2]
|
||||
host = sys.argv[3]
|
||||
pulse_url = sys.argv[1]
|
||||
host = sys.argv[2]
|
||||
backup_query = "backup_perms=true&" if sys.argv[3] == "true" else ""
|
||||
expires_raw = data.get("expires", "")
|
||||
expiry_state = ""
|
||||
try:
|
||||
@@ -2305,15 +2307,14 @@ setup_script_name = str(data.get("scriptFileName", ""))
|
||||
setup_command = str(data.get("command", ""))
|
||||
setup_command_with_env = str(data.get("commandWithEnv", ""))
|
||||
setup_command_without_env = str(data.get("commandWithoutEnv", ""))
|
||||
expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
|
||||
if setup_token:
|
||||
expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"
|
||||
else:
|
||||
expected_download_url = ""
|
||||
expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
|
||||
expected_download_url = expected_setup_url
|
||||
legacy_download_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve" if setup_token else ""
|
||||
modern = setup_download_url == expected_download_url
|
||||
expected_script_name = "pulse-setup-pve.sh"
|
||||
if setup_url != expected_setup_url:
|
||||
setup_url = ""
|
||||
if setup_download_url != expected_download_url:
|
||||
if setup_download_url not in (expected_download_url, legacy_download_url):
|
||||
setup_download_url = ""
|
||||
if setup_script_name != expected_script_name:
|
||||
setup_script_name = ""
|
||||
@@ -2345,15 +2346,17 @@ for _field_name, _value, _requires_token in command_fields:
|
||||
else:
|
||||
setup_command_without_env = ""
|
||||
continue
|
||||
if _requires_token:
|
||||
if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:
|
||||
if _field_name == "command":
|
||||
setup_command = ""
|
||||
else:
|
||||
setup_command_with_env = ""
|
||||
continue
|
||||
elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:
|
||||
setup_command_without_env = ""
|
||||
if modern:
|
||||
valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value
|
||||
else:
|
||||
valid = "PULSE_SETUP_TOKEN_FILE=" not in _value and (("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value))
|
||||
if not valid:
|
||||
if _field_name == "command":
|
||||
setup_command = ""
|
||||
elif _field_name == "commandWithEnv":
|
||||
setup_command_with_env = ""
|
||||
else:
|
||||
setup_command_without_env = ""
|
||||
if not token_hint or token_hint == setup_token:
|
||||
token_hint = ""
|
||||
print("\t".join([
|
||||
@@ -2373,7 +2376,9 @@ print("\t".join([
|
||||
PY
|
||||
)" || setup_token=""
|
||||
|
||||
local expected_setup_url="${pulse_url}/api/setup-script?host=$(python3 - <<'PY' "$normalized_host_url"
|
||||
local backup_query=""
|
||||
[[ "$backup_perms" == "true" ]] && backup_query="backup_perms=true&"
|
||||
local expected_setup_url="${pulse_url}/api/setup-script?${backup_query}host=$(python3 - <<'PY' "$normalized_host_url"
|
||||
from urllib.parse import quote
|
||||
import sys
|
||||
print(quote(sys.argv[1], safe=''))
|
||||
@@ -2384,16 +2389,18 @@ import sys
|
||||
print(quote(sys.argv[1], safe=''))
|
||||
PY
|
||||
)&type=pve"
|
||||
local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$setup_token"
|
||||
local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$backup_perms" 3<<<"$setup_token"
|
||||
from urllib.parse import quote
|
||||
import sys
|
||||
host, pulse_url, setup_token = sys.argv[1:]
|
||||
print(f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve")
|
||||
host, pulse_url, backup_perms = sys.argv[1:]
|
||||
backup_query = "backup_perms=true&" if backup_perms == "true" else ""
|
||||
setup_token = __import__("os").fdopen(3).read().strip()
|
||||
print(f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve")
|
||||
PY
|
||||
)"
|
||||
local expected_script_name="pulse-setup-pve.sh"
|
||||
|
||||
if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || [[ "$setup_download_url" != "$expected_download_url" ]] || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then
|
||||
if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || { [[ "$setup_download_url" != "$expected_download_url" ]] && [[ "$setup_download_url" != "$expected_setup_url" ]]; } || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then
|
||||
AUTO_NODE_REGISTER_ERROR="missing setup token"
|
||||
print_warn "Pulse did not return a setup token; skipping automatic node registration"
|
||||
return
|
||||
@@ -2512,9 +2519,10 @@ PY
|
||||
fi
|
||||
|
||||
local register_payload
|
||||
register_payload=$(python3 - <<'PY' "$normalized_host_url" "$token_id" "$token_value" "$server_name" "$setup_token"
|
||||
register_payload=$(python3 - "$normalized_host_url" "$token_id" "$server_name" 3<<<"$(printf '%s\n%s\n' "$token_value" "$setup_token")" <<'PY'
|
||||
import json, sys
|
||||
host, token_id, token_value, server_name, setup_token = sys.argv[1:]
|
||||
host, token_id, server_name = sys.argv[1:]
|
||||
token_value, setup_token = __import__("os").fdopen(3).read().splitlines()
|
||||
print(json.dumps({
|
||||
"type": "pve",
|
||||
"host": host,
|
||||
@@ -2528,7 +2536,7 @@ PY
|
||||
)
|
||||
|
||||
local register_response
|
||||
if ! register_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"); then
|
||||
if ! register_response=$(printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-); then
|
||||
AUTO_NODE_REGISTER_ERROR="auto-register request failed"
|
||||
print_warn "Pulse auto-registration request failed; skipping automatic node registration"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
// Read what the guide actually sends, rather than maintaining a second client.
|
||||
// Authentication, licensing and CSRF are tested by the existing route tests;
|
||||
// these documentation tests exercise the real handlers and persistent values.
|
||||
func adminDocBodies(t *testing.T, name, heading string) [][]byte {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("..", "..", "docs", name+".md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, section, found := strings.Cut(string(contents), "### "+heading+"\n")
|
||||
if !found {
|
||||
t.Fatalf("missing %s heading %q", name, heading)
|
||||
}
|
||||
if next := strings.Index(section, "\n##"); next >= 0 {
|
||||
section = section[:next]
|
||||
}
|
||||
// Both shell heredocs and API schema blocks contain standalone JSON.
|
||||
re := regexp.MustCompile("(?s)(?:<<'JSON'\\n|```json\\n)(.*?)(?:\\nJSON|\\n```)")
|
||||
var bodies [][]byte
|
||||
for _, match := range re.FindAllStringSubmatch(section, -1) {
|
||||
if !json.Valid([]byte(match[1])) {
|
||||
t.Fatalf("invalid JSON in %s/%s", name, heading)
|
||||
}
|
||||
bodies = append(bodies, []byte(match[1]))
|
||||
}
|
||||
if len(bodies) == 0 {
|
||||
t.Fatalf("missing request JSON in %s/%s", name, heading)
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func TestAdminDocsCustomRoleLifecycle(t *testing.T) {
|
||||
dataPath := t.TempDir()
|
||||
resetPersistentAuthStoresForTests()
|
||||
t.Cleanup(resetPersistentAuthStoresForTests)
|
||||
InitPersistentAuthStores(dataPath)
|
||||
const fixtureSession = "admin-docs-jane-session-fixture"
|
||||
GetSessionStore().CreateSession(fixtureSession, time.Hour, "fixture", "127.0.0.1", "jane")
|
||||
fixtureCSRF := GetCSRFStore().GenerateCSRFToken(fixtureSession)
|
||||
manager, err := auth.NewFileManager(dataPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := auth.GetManager()
|
||||
auth.SetManager(manager)
|
||||
t.Cleanup(func() { auth.SetManager(previous) })
|
||||
h := NewRBACHandlers(&config.Config{AuthUser: "alice"})
|
||||
|
||||
// Exact-base guide control: creating/updating the built-in operator fails.
|
||||
legacy := httptest.NewRecorder()
|
||||
h.HandleRoles(legacy, withUser(httptest.NewRequest(http.MethodPost, "/api/admin/roles",
|
||||
strings.NewReader(`{"id":"operator","name":"Operator"}`)), "alice"))
|
||||
if legacy.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("legacy built-in mutation was not rejected: %d", legacy.Code)
|
||||
}
|
||||
|
||||
assignments := adminDocBodies(t, "RBAC", "Setting Roles for a User")
|
||||
if len(assignments) != 2 {
|
||||
t.Fatalf("want assign and clear examples, got %d", len(assignments))
|
||||
}
|
||||
steps := []struct {
|
||||
method, path string
|
||||
body []byte
|
||||
handler http.HandlerFunc
|
||||
status int
|
||||
}{
|
||||
{http.MethodPost, "/api/admin/roles", adminDocBodies(t, "RBAC", "Creating a Role")[0], h.HandleRoles, http.StatusOK},
|
||||
{http.MethodPut, "/api/admin/roles/alert-manager", adminDocBodies(t, "RBAC", "Updating a Role")[0], h.HandleRoles, http.StatusOK},
|
||||
{http.MethodPut, "/api/admin/users/jane/roles", assignments[0], h.HandleUserRoleActions, http.StatusNoContent},
|
||||
{http.MethodPut, "/api/admin/users/jane/roles", assignments[1], h.HandleUserRoleActions, http.StatusNoContent},
|
||||
{http.MethodDelete, "/api/admin/users/jane", nil, h.HandleUserRoleActions, http.StatusNoContent},
|
||||
{http.MethodDelete, "/api/admin/roles/alert-manager", nil, h.HandleRoles, http.StatusNoContent},
|
||||
}
|
||||
for _, step := range steps {
|
||||
rec := httptest.NewRecorder()
|
||||
step.handler(rec, withUser(httptest.NewRequest(step.method, step.path, bytes.NewReader(step.body)), "alice"))
|
||||
if rec.Code != step.status {
|
||||
t.Fatalf("documented %s %s: got %d: %s", step.method, step.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if step.method == http.MethodPut && step.path == "/api/admin/roles/alert-manager" {
|
||||
role, exists := manager.GetRole("alert-manager")
|
||||
if !exists || role.IsBuiltIn || len(role.Permissions) != 4 {
|
||||
t.Fatalf("custom role update did not persist its permissions: %+v", role)
|
||||
}
|
||||
}
|
||||
if step.method == http.MethodPut && strings.HasSuffix(step.path, "/jane/roles") {
|
||||
assignment, exists := manager.GetUserAssignment("jane")
|
||||
var want struct {
|
||||
RoleIDs []string `json:"roleIds"`
|
||||
}
|
||||
if err := json.Unmarshal(step.body, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists || strings.Join(assignment.RoleIDs, ",") != strings.Join(want.RoleIDs, ",") {
|
||||
t.Fatalf("assignment did not persist the complete role list: %+v", assignment)
|
||||
}
|
||||
}
|
||||
if step.method == http.MethodDelete && step.path == "/api/admin/users/jane" {
|
||||
if _, exists := manager.GetUserAssignment("jane"); exists {
|
||||
t.Fatal("user removal retained the role assignment")
|
||||
}
|
||||
if GetSessionStore().GetSession(fixtureSession) != nil || GetCSRFStore().ValidateCSRFToken(fixtureSession, fixtureCSRF) {
|
||||
t.Fatal("user removal retained the session or its CSRF token")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := manager.GetRole("alert-manager"); exists {
|
||||
t.Fatal("custom role deletion did not persist")
|
||||
}
|
||||
if role, exists := manager.GetRole("operator"); !exists || !role.IsBuiltIn {
|
||||
t.Fatal("examples altered the built-in role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDocsOrganizationSchemasAndAcceptance(t *testing.T) {
|
||||
t.Setenv("PULSE_DEV", "true")
|
||||
wasEnabled := IsMultiTenantEnabled()
|
||||
SetMultiTenantEnabled(true)
|
||||
t.Cleanup(func() { SetMultiTenantEnabled(wasEnabled) })
|
||||
persistence := config.NewMultiTenantPersistence(t.TempDir())
|
||||
h := NewOrgHandlers(persistence, nil)
|
||||
call := func(method, path, user, orgID string, body []byte, handler http.HandlerFunc) *httptest.ResponseRecorder {
|
||||
req := withUser(httptest.NewRequest(method, path, bytes.NewReader(body)), user)
|
||||
req.SetPathValue("id", orgID)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
return rec
|
||||
}
|
||||
check := func(rec *httptest.ResponseRecorder, status int) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("documented request: got %d, want %d: %s", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Exact-base guide controls: wrong creation fields and member PATCH fail.
|
||||
check(call(http.MethodPost, "/api/orgs", "alice", "", []byte(`{"name":"Production Datacenter","description":"EU production infrastructure"}`), h.HandleCreateOrg), http.StatusBadRequest)
|
||||
check(call(http.MethodPatch, "/api/orgs/production-datacenter/members/user-id", "alice", "production-datacenter", []byte(`{"role":"admin"}`), h.HandleInviteMember), http.StatusMethodNotAllowed)
|
||||
|
||||
creation := adminDocBodies(t, "MULTI_TENANT", "Creating an Organization")[0]
|
||||
check(call(http.MethodPost, "/api/orgs", "alice", "", creation, h.HandleCreateOrg), http.StatusCreated)
|
||||
check(call(http.MethodPost, "/api/orgs", "bob", "", []byte(`{"id":"other-org-id","displayName":"Other estate"}`), h.HandleCreateOrg), http.StatusCreated)
|
||||
members := adminDocBodies(t, "MULTI_TENANT", "Managing Members")
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("want invitation and existing-member update, got %d", len(members))
|
||||
}
|
||||
invitation := call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[0], h.HandleInviteMember)
|
||||
check(invitation, http.StatusAccepted)
|
||||
var invited organizationAccessMutationResponse
|
||||
if err := json.Unmarshal(invitation.Body.Bytes(), &invited); err != nil || invited.Kind != "invitation" || invited.Member != nil {
|
||||
t.Fatalf("new member was not pending: %s", invitation.Body.String())
|
||||
}
|
||||
acceptInvitationForTest(t, h, "production-datacenter", "user-id")
|
||||
check(call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[1], h.HandleInviteMember), http.StatusOK)
|
||||
org, err := persistence.LoadOrganization("production-datacenter")
|
||||
if err != nil || organizationRoleForUser(org, "user-id") != models.OrgRoleAdmin {
|
||||
t.Fatalf("documented role update did not persist: %v", err)
|
||||
}
|
||||
|
||||
check(call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", []byte(`{"targetOrgId":"other-org-id","resourceType":"host","resourceId":"resource-id","role":"viewer"}`), h.HandleCreateShare), http.StatusBadRequest)
|
||||
shareBody := adminDocBodies(t, "MULTI_TENANT", "Sharing Resources")[0]
|
||||
created := call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", shareBody, h.HandleCreateShare)
|
||||
check(created, http.StatusCreated)
|
||||
var share models.OrganizationShare
|
||||
if err := json.Unmarshal(created.Body.Bytes(), &share); err != nil || share.Status != models.OrganizationShareStatusPending || share.AccessRole != models.OrgRoleViewer {
|
||||
t.Fatalf("documented share grant was not pending/viewer: %s", created.Body.String())
|
||||
}
|
||||
accepted := acceptIncomingShareForTest(t, h, "other-org-id", "bob", share.ID)
|
||||
if accepted.Status != models.OrganizationShareStatusAccepted {
|
||||
t.Fatal("share acceptance did not persist")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
@@ -142,19 +141,7 @@ func buildContainerRuntimeAgentInstallCommand(baseURL string, token string, enab
|
||||
}
|
||||
|
||||
func buildSetupScriptCommand(scriptURL string, token string) string {
|
||||
curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | "
|
||||
bashCommand := "bash"
|
||||
sudoCommand := "sudo bash"
|
||||
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
|
||||
envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " "
|
||||
bashCommand = envPrefix + bashCommand
|
||||
sudoCommand = "sudo env " + envPrefix + "bash"
|
||||
}
|
||||
|
||||
return curlCommand +
|
||||
`{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand +
|
||||
`; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand +
|
||||
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
|
||||
return configapi.BuildSetupScriptCommand(scriptURL, token)
|
||||
}
|
||||
|
||||
func buildSetupScriptTokenHint(token string) string {
|
||||
@@ -166,40 +153,11 @@ func buildSetupScriptTokenHint(token string) string {
|
||||
}
|
||||
|
||||
func buildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string {
|
||||
query := url.Values{}
|
||||
query.Set("type", strings.TrimSpace(installType))
|
||||
|
||||
if trimmedHost := strings.TrimSpace(host); trimmedHost != "" {
|
||||
query.Set("host", trimmedHost)
|
||||
}
|
||||
|
||||
if trimmedPulseURL := strings.TrimSpace(pulseURL); trimmedPulseURL != "" {
|
||||
query.Set("pulse_url", trimmedPulseURL)
|
||||
}
|
||||
|
||||
if backupPerms && strings.TrimSpace(installType) == "pve" {
|
||||
query.Set("backup_perms", "true")
|
||||
}
|
||||
|
||||
return normalizeAgentInstallBaseURL(baseURL) + "/api/setup-script?" + query.Encode()
|
||||
return configapi.BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
|
||||
}
|
||||
|
||||
func buildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string {
|
||||
downloadURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
|
||||
trimmedToken := strings.TrimSpace(setupToken)
|
||||
if trimmedToken == "" {
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("setup_token", trimmedToken)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
return configapi.BuildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken)
|
||||
}
|
||||
|
||||
func buildSetupScriptFileName(installType string) string {
|
||||
@@ -207,22 +165,7 @@ func buildSetupScriptFileName(installType string) string {
|
||||
}
|
||||
|
||||
func buildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) setupScriptInstallArtifact {
|
||||
scriptURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
|
||||
commandWithEnv := buildSetupScriptCommand(scriptURL, setupToken)
|
||||
|
||||
return setupScriptInstallArtifact{
|
||||
Type: strings.TrimSpace(installType),
|
||||
Host: strings.TrimSpace(host),
|
||||
URL: scriptURL,
|
||||
DownloadURL: buildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken),
|
||||
ScriptFileName: buildSetupScriptFileName(installType),
|
||||
Command: commandWithEnv,
|
||||
CommandWithEnv: commandWithEnv,
|
||||
CommandWithoutEnv: buildSetupScriptCommand(scriptURL, ""),
|
||||
Expires: expiresAt,
|
||||
SetupToken: strings.TrimSpace(setupToken),
|
||||
TokenHint: buildSetupScriptTokenHint(setupToken),
|
||||
}
|
||||
return configapi.BuildSetupScriptInstallArtifact(baseURL, installType, host, pulseURL, backupPerms, setupToken, expiresAt)
|
||||
}
|
||||
|
||||
func resolveConfigAgentInstallBaseURL(req *http.Request, cfg *config.Config, hostedMode bool) string {
|
||||
|
||||
@@ -89,12 +89,12 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) {
|
||||
IncludeInstallType: true,
|
||||
})
|
||||
require.Contains(t, command, posixShellQuote("https://pulse.example.com/install.sh"))
|
||||
require.Contains(t, command, "printf %s "+posixShellQuote("token-123")+` > "$token_file"`)
|
||||
require.NotContains(t, command, "token-123")
|
||||
require.Contains(t, command, `--token-file "$token_file"`)
|
||||
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
|
||||
require.Contains(t, command, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
|
||||
require.Contains(t, command, `rm -rf -- "$token_dir"`)
|
||||
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
|
||||
require.Contains(t, command, `sudo bash -c`)
|
||||
require.Contains(t, command, `rmdir -- "$token_dir"`)
|
||||
require.Contains(t, command, "pbs")
|
||||
require.NotContains(t, command, "--enable-commands")
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesInsecureForPlainHTTP(t *testing
|
||||
})
|
||||
|
||||
require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655"))
|
||||
require.Contains(t, command, "http://pulse.example.com:7655")
|
||||
require.Contains(t, command, "--insecure")
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesExplicitInsecureForSelfSignedHT
|
||||
})
|
||||
|
||||
require.Contains(t, command, "curl -kfsSL "+posixShellQuote("https://pulse.example.com:7655/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com:7655"))
|
||||
require.Contains(t, command, "https://pulse.example.com:7655")
|
||||
require.Contains(t, command, "--insecure")
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ func TestBuildProxmoxAgentInstallCommand_UsesPrivilegeEscalationWrapper(t *testi
|
||||
|
||||
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`)
|
||||
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then`)
|
||||
require.Contains(t, command, `printf %s 'token-123' | sudo tee "$token_file" >/dev/null`)
|
||||
require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' | sudo bash -s --`)
|
||||
require.NotContains(t, command, "token-123")
|
||||
require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' -o "$install_script"`)
|
||||
require.Contains(t, command, `echo "Root privileges required. Run as root (su -) and retry." >&2`)
|
||||
}
|
||||
|
||||
@@ -148,8 +148,8 @@ func TestBuildProxmoxAgentInstallCommand_OmitsTokenWhenNotProvided(t *testing.T)
|
||||
IncludeInstallType: true,
|
||||
})
|
||||
|
||||
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com"))
|
||||
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
|
||||
require.Contains(t, command, "https://pulse.example.com")
|
||||
require.Contains(t, command, "pbs")
|
||||
require.NotContains(t, command, "--token")
|
||||
}
|
||||
|
||||
@@ -164,10 +164,10 @@ func TestBuildProxmoxAgentInstallCommand_ShellEscapesArguments(t *testing.T) {
|
||||
})
|
||||
|
||||
require.Contains(t, command, posixShellQuote(baseURL+"/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote(baseURL))
|
||||
require.Contains(t, command, "printf %s "+posixShellQuote(token)+` > "$token_file"`)
|
||||
require.Contains(t, command, "--url")
|
||||
require.NotContains(t, command, token)
|
||||
require.Contains(t, command, `--token-file "$token_file"`)
|
||||
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
|
||||
require.Contains(t, command, "pve")
|
||||
}
|
||||
|
||||
func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T) {
|
||||
@@ -179,10 +179,11 @@ func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T)
|
||||
})
|
||||
|
||||
require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
|
||||
require.Contains(t, command, "https://pulse.example.com/base")
|
||||
require.Contains(t, command, `--token-file "$token_file"`)
|
||||
require.NotContains(t, command, "//install.sh")
|
||||
require.NotContains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base/"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
|
||||
require.NotContains(t, command, posixShellQuote("https://pulse.example.com/base/"))
|
||||
}
|
||||
|
||||
func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testing.T) {
|
||||
@@ -195,17 +196,17 @@ func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testin
|
||||
})
|
||||
|
||||
require.Contains(t, command, "--enable-proxmox")
|
||||
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
|
||||
require.Contains(t, command, "pve")
|
||||
require.Contains(t, command, "--enable-commands")
|
||||
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
|
||||
require.Contains(t, command, `rm -rf -- "$token_dir"`)
|
||||
require.Contains(t, command, `rmdir -- "$token_dir"`)
|
||||
}
|
||||
|
||||
func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *testing.T) {
|
||||
command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com/base", "token-123", true)
|
||||
|
||||
require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
|
||||
require.Contains(t, command, "https://pulse.example.com/base")
|
||||
require.Contains(t, command, "--token "+posixShellQuote("token-123"))
|
||||
require.Contains(t, command, "--enable-docker")
|
||||
require.Contains(t, command, "--enable-host")
|
||||
@@ -220,7 +221,7 @@ func TestBuildContainerRuntimeAgentInstallCommand_OmitsTokenAndAddsInsecureForHT
|
||||
command := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "", true)
|
||||
|
||||
require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh"))
|
||||
require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655"))
|
||||
require.Contains(t, command, "http://pulse.example.com:7655")
|
||||
require.NotContains(t, command, "--token")
|
||||
require.Contains(t, command, "--insecure")
|
||||
}
|
||||
@@ -246,18 +247,18 @@ func TestContainerRuntimeAgentScopesFollowHostMode(t *testing.T) {
|
||||
func TestBuildSetupScriptCommand_UsesFailFastQuotedTransport(t *testing.T) {
|
||||
command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local", "token-123")
|
||||
|
||||
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+" | ")
|
||||
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`)
|
||||
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`)
|
||||
require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`)
|
||||
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+` -o "$install_script"`)
|
||||
require.NotContains(t, command, "token-123")
|
||||
require.Contains(t, command, `PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"`)
|
||||
require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`)
|
||||
}
|
||||
|
||||
func TestBuildSetupScriptCommand_OmitsTokenWhenNotProvided(t *testing.T) {
|
||||
command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pbs", "")
|
||||
|
||||
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+" | ")
|
||||
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then bash`)
|
||||
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo bash`)
|
||||
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+` -o "$install_script"`)
|
||||
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`)
|
||||
require.Contains(t, command, `sudo bash -c`)
|
||||
require.NotContains(t, command, "PULSE_SETUP_TOKEN=")
|
||||
}
|
||||
|
||||
@@ -293,10 +294,11 @@ func TestBuildSetupScriptInstallArtifact_UsesSharedBackendShape(t *testing.T) {
|
||||
require.Equal(t, "pve", artifact.Type)
|
||||
require.Equal(t, "https://pve1.local:8006", artifact.Host)
|
||||
require.Contains(t, artifact.URL, "/api/setup-script?")
|
||||
require.Contains(t, artifact.DownloadURL, "setup_token=setup-token-123")
|
||||
require.Equal(t, artifact.URL, artifact.DownloadURL)
|
||||
require.Equal(t, "pulse-setup-pve.sh", artifact.ScriptFileName)
|
||||
require.Equal(t, artifact.Command, artifact.CommandWithEnv)
|
||||
require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'")
|
||||
require.NotContains(t, artifact.CommandWithEnv, "setup-token-123")
|
||||
require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=")
|
||||
require.NotContains(t, artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=")
|
||||
require.Equal(t, expiresAt, artifact.Expires)
|
||||
require.Equal(t, "setup-token-123", artifact.SetupToken)
|
||||
@@ -331,7 +333,7 @@ fi`,
|
||||
})
|
||||
|
||||
require.Contains(t, script, `SETUP_SCRIPT_URL="https://pulse.example/api/setup-script?backup_perms=true&host=https%3A%2F%2Fpve1.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve"`)
|
||||
require.Contains(t, script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'https://pulse.example/api/setup-script?backup_perms=true&host=https%3A%2F%2Fpve1.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve'"'"' | `)
|
||||
require.Contains(t, script, "PULSE_BOOTSTRAP_COMMAND_WITH_ENV="+posixShellQuote(artifact.CommandWithEnv))
|
||||
require.Contains(t, script, `PULSE_SETUP_TOKEN="${PULSE_SETUP_TOKEN:-setup-token-123}"`)
|
||||
require.Contains(t, script, `pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin`)
|
||||
require.Contains(t, script, `pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin`)
|
||||
|
||||
@@ -542,10 +542,10 @@ func TestHandleAgentInstallCommand(t *testing.T) {
|
||||
if resp.Token == "" || resp.Command == "" {
|
||||
t.Fatalf("expected token and command in response")
|
||||
}
|
||||
if !bytes.Contains([]byte(resp.Command), []byte(resp.Token)) {
|
||||
t.Fatalf("expected command to include token")
|
||||
if bytes.Contains([]byte(resp.Command), []byte(resp.Token)) || !strings.Contains(resp.Command, `--token-file "$token_file"`) {
|
||||
t.Fatal("credential must be entered privately, not included in copied command")
|
||||
}
|
||||
if !bytes.Contains([]byte(resp.Command), []byte("--proxmox-type "+posixShellQuote("pve"))) {
|
||||
if resp.Command != BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "http://example.com:8080", Token: resp.Token, InstallType: "pve", IncludeInstallType: true, EnableCommands: true}) {
|
||||
t.Fatalf("expected command to include proxmox type")
|
||||
}
|
||||
if len(cfg.APITokens) != 1 {
|
||||
@@ -633,7 +633,7 @@ func TestHandleAgentInstallCommand_NormalizesType(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !bytes.Contains([]byte(resp.Command), []byte("--proxmox-type "+posixShellQuote("pbs"))) {
|
||||
if resp.Command != BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "http://example.com:8080", Token: resp.Token, InstallType: "pbs", IncludeInstallType: true}) {
|
||||
t.Fatalf("expected normalized proxmox type in command, got: %s", resp.Command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ func TestPVESetupScript_UsesFailFastRetryGuidance(t *testing.T) {
|
||||
}
|
||||
|
||||
script := rr.Body.String()
|
||||
if !containsString(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | `) {
|
||||
if !containsString(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV=`+posixShellQuote(BuildSetupScriptCommand("http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve", ""))) {
|
||||
t.Fatalf("expected canonical bootstrap command owner in setup script, got: %s", truncate(script, 700))
|
||||
}
|
||||
if !containsString(script, `echo " $PULSE_BOOTSTRAP_COMMAND_WITH_ENV"`) {
|
||||
@@ -952,7 +952,8 @@ func TestPVESetupScriptSmokeTestsCreatedTokenBeforeAutoRegistration(t *testing.T
|
||||
for _, needle := range []string{
|
||||
`smoke_test_pve_token() {`,
|
||||
`curl -kfsS --retry 2 --retry-delay 1`,
|
||||
`Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`,
|
||||
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl`,
|
||||
`-H @-`,
|
||||
`${HOST_URL%/}/api2/json/nodes`,
|
||||
`TOKEN_READY=false`,
|
||||
`if smoke_test_pve_token; then`,
|
||||
|
||||
@@ -60,12 +60,10 @@ func TestHandleSetupScriptURL(t *testing.T) {
|
||||
if tokenHint == token {
|
||||
t.Errorf("expected tokenHint to mask setup token, got %q", tokenHint)
|
||||
}
|
||||
if !strings.Contains(downloadURL, "setup_token=") {
|
||||
t.Errorf("expected downloadURL to embed setup token, got %q", downloadURL)
|
||||
}
|
||||
if !strings.Contains(downloadURL, token) {
|
||||
t.Errorf("expected downloadURL to contain setup token, got %q", downloadURL)
|
||||
if downloadURL != url || strings.Contains(downloadURL, token) {
|
||||
t.Errorf("downloadURL must match the credential-free script URL")
|
||||
}
|
||||
|
||||
respType, ok := resp["type"].(string)
|
||||
if !ok || respType != "pve" {
|
||||
t.Errorf("expected canonical type, got %v", resp["type"])
|
||||
@@ -92,16 +90,12 @@ func TestHandleSetupScriptURL(t *testing.T) {
|
||||
t.Errorf("expected URL to contain public host, got %s", url)
|
||||
}
|
||||
quotedURL := posixShellQuote(url)
|
||||
if !strings.Contains(command, "curl -fsSL "+quotedURL+" | ") ||
|
||||
!strings.Contains(command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) ||
|
||||
!strings.Contains(command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) {
|
||||
t.Errorf("expected shell-quoted command, got %s", command)
|
||||
}
|
||||
if !strings.Contains(commandWithoutEnv, "curl -fsSL "+quotedURL+" | ") ||
|
||||
!strings.Contains(commandWithoutEnv, `if [ "$(id -u)" -eq 0 ]; then bash`) ||
|
||||
!strings.Contains(commandWithoutEnv, `elif command -v sudo >/dev/null 2>&1; then sudo bash`) {
|
||||
t.Errorf("expected shell-quoted commandWithoutEnv, got %s", commandWithoutEnv)
|
||||
for _, candidate := range []string{command, commandWithoutEnv} {
|
||||
if !strings.Contains(candidate, "curl -fsSL "+quotedURL+` -o "$install_script"`) || strings.Contains(candidate, token) || !strings.Contains(candidate, "PULSE_SETUP_TOKEN_FILE=") || !strings.Contains(candidate, "sudo bash -c") {
|
||||
t.Error("setup command must download completely, prompt privately, and pass only the token file")
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -296,7 +290,7 @@ func TestHandleSetupScriptURL_PreservesConfiguredPublicURLSchemeOnLoopback(t *te
|
||||
}
|
||||
|
||||
command, _ := response["command"].(string)
|
||||
if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+" | ") || !strings.Contains(command, `sudo env PULSE_SETUP_TOKEN=`) {
|
||||
if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+` -o "$install_script"`) || !strings.Contains(command, `sudo bash -c`) {
|
||||
t.Fatalf("command = %q, want quoted setup-script URL", command)
|
||||
}
|
||||
}
|
||||
@@ -323,8 +317,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi
|
||||
if artifact.URL == "" || !strings.Contains(artifact.URL, "/api/setup-script?") {
|
||||
t.Fatalf("url = %q, want canonical setup-script url", artifact.URL)
|
||||
}
|
||||
if artifact.DownloadURL == "" || !strings.Contains(artifact.DownloadURL, "setup_token=setup-token-123") {
|
||||
t.Fatalf("downloadURL = %q, want setup token embedded", artifact.DownloadURL)
|
||||
if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, "setup-token-123") {
|
||||
t.Fatalf("downloadURL = %q, want credential-free URL", artifact.DownloadURL)
|
||||
}
|
||||
if artifact.ScriptFileName != "pulse-setup-pve.sh" {
|
||||
t.Fatalf("scriptFileName = %q, want canonical filename", artifact.ScriptFileName)
|
||||
@@ -332,8 +326,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi
|
||||
if artifact.Command != artifact.CommandWithEnv {
|
||||
t.Fatalf("command = %q, commandWithEnv = %q, want identical canonical env command", artifact.Command, artifact.CommandWithEnv)
|
||||
}
|
||||
if !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'") {
|
||||
t.Fatalf("commandWithEnv = %q, want setup token env transport", artifact.CommandWithEnv)
|
||||
if strings.Contains(artifact.CommandWithEnv, "setup-token-123") || !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=") {
|
||||
t.Fatalf("commandWithEnv = %q, want private token-file transport", artifact.CommandWithEnv)
|
||||
}
|
||||
if strings.Contains(artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=") {
|
||||
t.Fatalf("commandWithoutEnv = %q, want no setup token env transport", artifact.CommandWithoutEnv)
|
||||
|
||||
@@ -51,8 +51,12 @@ func TestHostedRuntimeMintsAgentInstallTokensWithoutLocalAuth(t *testing.T) {
|
||||
if len(cfg.APITokens) != 1 {
|
||||
t.Fatalf("persisted API tokens = %d, want 1", len(cfg.APITokens))
|
||||
}
|
||||
if resp.Command != "" && !strings.Contains(resp.Command, resp.Token) {
|
||||
t.Fatalf("install command does not carry the minted token: %s", resp.Command)
|
||||
if tc.name == "proxmox agent" {
|
||||
if strings.Contains(resp.Command, resp.Token) || !strings.Contains(resp.Command, `--token-file "$token_file"`) {
|
||||
t.Fatal("hosted Proxmox credential must use separate private input")
|
||||
}
|
||||
} else if resp.Command != "" && !strings.Contains(resp.Command, resp.Token) {
|
||||
t.Fatal("generic host command lost its existing credential transport")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -16,60 +15,68 @@ type AgentInstallCommandOptions struct {
|
||||
|
||||
type agentInstallCommandOptions = AgentInstallCommandOptions
|
||||
|
||||
// BuildProxmoxAgentInstallCommand never puts the credential in shell source.
|
||||
// Token only selects whether private terminal input is required. The plaintext
|
||||
// remains in the authenticated response's separate token field.
|
||||
func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
|
||||
installScriptURL := baseURL + "/install.sh"
|
||||
curlFlags := "-fsSL"
|
||||
if opts.Insecure {
|
||||
curlFlags = "-kfsSL"
|
||||
}
|
||||
token := strings.TrimSpace(opts.Token)
|
||||
command := fmt.Sprintf("curl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
|
||||
trustArgs := ""
|
||||
if opts.Insecure || strings.HasPrefix(strings.ToLower(baseURL), "http://") {
|
||||
command += " \\\n --insecure"
|
||||
trustArgs = " --insecure"
|
||||
}
|
||||
args := " --url " + posixShellQuote(baseURL) + " --enable-proxmox"
|
||||
if opts.IncludeInstallType {
|
||||
command += fmt.Sprintf(" \\\n --proxmox-type %s", posixShellQuote(opts.InstallType))
|
||||
args += " --proxmox-type " + posixShellQuote(opts.InstallType)
|
||||
}
|
||||
if opts.EnableCommands {
|
||||
command += " \\\n --enable-commands"
|
||||
args += " --enable-commands"
|
||||
}
|
||||
if token == "" {
|
||||
return withPrivilegeEscalation(command)
|
||||
if strings.TrimSpace(opts.Token) != "" {
|
||||
args += ` --token-file "$token_file"`
|
||||
}
|
||||
args += trustArgs + " --non-interactive"
|
||||
preflight := "bash \"$install_script\" --url " + posixShellQuote(baseURL) +
|
||||
" --preflight-only --output json --non-interactive" + trustArgs + ";"
|
||||
return privateBootstrapCommand(baseURL+"/install.sh", curlFlags, preflight,
|
||||
"bash \"$1\""+args+";", strings.TrimSpace(opts.Token) != "", "Pulse agent token")
|
||||
}
|
||||
|
||||
rootCommand := command + " \\\n --token-file \"$token_file\""
|
||||
sudoCommand := strings.Replace(command, "| bash -s --", "| sudo bash -s --", 1) + " \\\n --token-file \"$token_file\""
|
||||
return fmt.Sprintf(`(
|
||||
set -e
|
||||
token_dir=""
|
||||
cleanup() {
|
||||
if [ -n "${token_dir:-}" ]; then
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
rm -rf -- "$token_dir"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
|
||||
token_file="$token_dir/token"
|
||||
umask 077
|
||||
printf %%s %s > "$token_file"
|
||||
%s
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
|
||||
token_file="$token_dir/token"
|
||||
printf %%s %s | sudo tee "$token_file" >/dev/null
|
||||
sudo chmod 0600 "$token_file"
|
||||
%s
|
||||
else
|
||||
echo "Root privileges required. Run as root (su -) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
)`, posixShellQuote(token), rootCommand, posixShellQuote(token), sudoCommand)
|
||||
// privateBootstrapCommand uses POSIX outer grammar for single-line paste hosts.
|
||||
// The privileged Bash child owns both the silent input and its 0700/0600
|
||||
// handoff. Neither sudo nor a child command receives the credential in argv or
|
||||
// environment. Failed/partial downloads and preflight never reach that child.
|
||||
func privateBootstrapCommand(scriptURL, curlFlags, preflight, run string, needsToken bool, prompt string) string {
|
||||
privileged := []string{"set +xv;", "set -eu;", "umask 077;"}
|
||||
if needsToken {
|
||||
privileged = append(privileged,
|
||||
`token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);`,
|
||||
`token_file="$token_dir/token";`,
|
||||
`cleanup() { unset pulse_token; rm -f -- "$token_file"; rmdir -- "$token_dir"; };`,
|
||||
`trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`,
|
||||
`if ! IFS= read -r -s -p `+posixShellQuote(prompt+" (paste at this prompt, not in the command): ")+` pulse_token </dev/tty; then echo "A terminal is required to enter the token. No installation was attempted." >&2; exit 1; fi;`,
|
||||
`printf '\n' >/dev/tty;`,
|
||||
`if [ -z "$pulse_token" ] || [ "${#pulse_token}" -gt 4096 ]; then echo "A non-empty token of at most 4096 characters is required." >&2; exit 1; fi;`,
|
||||
`printf %s "$pulse_token" > "$token_file"; unset pulse_token;`,
|
||||
)
|
||||
}
|
||||
privileged = append(privileged, run)
|
||||
child := "bash -c " + posixShellQuote(strings.Join(privileged, " ")) + ` pulse-bootstrap "$install_script";`
|
||||
return strings.Join([]string{
|
||||
"(", "set +xv;", "set -eu;", "umask 077;",
|
||||
`if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then sudo -v; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`,
|
||||
`bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX);`,
|
||||
`install_script="$bootstrap_dir/install.sh";`,
|
||||
`cleanup() { rm -f -- "$install_script"; rmdir -- "$bootstrap_dir"; };`,
|
||||
`trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`,
|
||||
"curl " + curlFlags + " " + posixShellQuote(strings.TrimSpace(scriptURL)) + ` -o "$install_script";`,
|
||||
preflight,
|
||||
`if [ "$(id -u)" -eq 0 ]; then ` + child + " else sudo " + child + " fi;",
|
||||
")",
|
||||
}, " ")
|
||||
}
|
||||
|
||||
func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
|
||||
|
||||
@@ -1,84 +1,73 @@
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("executes the generated POSIX shell command")
|
||||
}
|
||||
// A real controlling terminal is necessary: stdin must not accidentally become
|
||||
// a credential channel for a downloaded/piped script. The token travels only
|
||||
// over the PTY after the prompt appears, not Python/child argv or environment.
|
||||
const bootstrapPTYRunner = `
|
||||
import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time
|
||||
p = json.load(sys.stdin)
|
||||
master, slave = pty.openpty()
|
||||
def session():
|
||||
os.setsid()
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
history = p.get("history")
|
||||
env = os.environ.copy()
|
||||
if history:
|
||||
env.update(HISTFILE=history, HISTSIZE="100", HISTFILESIZE="100", HISTCONTROL="", PS1="PULSE_TEST$ ")
|
||||
program = ["bash", "--noprofile", "--norc", "-i"] if history else ["sh", "-c", p["command"]]
|
||||
child = subprocess.Popen(program, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, env=env)
|
||||
if history: os.write(master, (p["command"] + "\n").encode())
|
||||
os.close(slave)
|
||||
output = b""
|
||||
sent = False
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([master], [], [], .1)
|
||||
if ready:
|
||||
try: chunk = os.read(master, 65536)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO: break
|
||||
raise
|
||||
if not chunk: break
|
||||
output += chunk
|
||||
if output.endswith(b"(paste at this prompt, not in the command): ") and not sent:
|
||||
os.write(master, (p["input"] + "\n").encode())
|
||||
sent = True
|
||||
if history: os.write(master, b"exit\n")
|
||||
if child.poll() is not None and not ready: break
|
||||
else:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
output += b"\nPTY fixture deadline expired\n"
|
||||
code = child.wait()
|
||||
os.close(master)
|
||||
sys.stdout.buffer.write(output)
|
||||
sys.exit(code if code >= 0 else 128 - code)
|
||||
`
|
||||
|
||||
fixtureDir := t.TempDir()
|
||||
binDir := filepath.Join(fixtureDir, "bin")
|
||||
if err := os.Mkdir(binDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installerPath := filepath.Join(fixtureDir, "installer.sh")
|
||||
capturePath := filepath.Join(fixtureDir, "captured-token")
|
||||
writeExecutable(t, installerPath, `#!/usr/bin/env bash
|
||||
set -e
|
||||
token_file=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--token-file) token_file="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$token_file" ]
|
||||
[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ]
|
||||
parent_dir=$(dirname "$token_file")
|
||||
[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ]
|
||||
[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ]
|
||||
cat "$token_file" > "$FAKE_CAPTURE"
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(binDir, "curl"), `#!/bin/sh
|
||||
cat "$FAKE_INSTALLER"
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(binDir, "sudo"), `#!/bin/sh
|
||||
exec "$@"
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(binDir, "id"), `#!/bin/sh
|
||||
if [ "$1" = "-u" ]; then
|
||||
printf '%s\n' "$FAKE_ID_UID"
|
||||
else
|
||||
exec /usr/bin/id "$@"
|
||||
fi
|
||||
`)
|
||||
|
||||
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{
|
||||
BaseURL: "https://pulse.example",
|
||||
Token: "token-123",
|
||||
InstallType: "pve",
|
||||
IncludeInstallType: true,
|
||||
})
|
||||
for _, fakeUID := range []string{"0", "1000"} {
|
||||
t.Run("uid_"+fakeUID, func(t *testing.T) {
|
||||
if err := os.Remove(capturePath); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("bash", "-c", command)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+binDir+":"+os.Getenv("PATH"),
|
||||
"FAKE_CAPTURE="+capturePath,
|
||||
"FAKE_ID_UID="+fakeUID,
|
||||
"FAKE_INSTALLER="+installerPath,
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generated command failed: %v\n%s\ncommand:\n%s", err, output, command)
|
||||
}
|
||||
got, err := os.ReadFile(capturePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "token-123" {
|
||||
t.Fatalf("captured token = %q, want token-123", got)
|
||||
}
|
||||
})
|
||||
func runBootstrap(t *testing.T, command, token string, env []string, tty bool) ([]byte, error) {
|
||||
t.Helper()
|
||||
var cmd *exec.Cmd
|
||||
if tty {
|
||||
payload, err := json.Marshal(map[string]string{"command": command, "input": token})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd = exec.Command("python3", "-c", bootstrapPTYRunner)
|
||||
cmd.Stdin = strings.NewReader(string(payload))
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", command)
|
||||
}
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
func writeExecutable(t *testing.T, path, body string) {
|
||||
@@ -87,3 +76,321 @@ func writeExecutable(t *testing.T, path, body string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func bootstrapFixture(t *testing.T, installer string) (string, []string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(root, "bin")
|
||||
if err := os.Mkdir(bin, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeExecutable(t, filepath.Join(root, "installer.sh"), installer)
|
||||
writeExecutable(t, filepath.Join(bin, "curl"), `#!/bin/sh
|
||||
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
|
||||
out=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in -o) out="$2"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
printf %s "$out" > "$FAKE_ROOT/download-path"
|
||||
cp "$FAKE_ROOT/installer.sh" "$out"
|
||||
exit "${FAKE_CURL_EXIT:-0}"
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(bin, "sudo"), `#!/bin/sh
|
||||
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
|
||||
if [ "$1" = "-v" ]; then exit "${FAKE_SUDO_EXIT:-0}"; fi
|
||||
exec "$@"
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(bin, "id"), `#!/bin/sh
|
||||
if [ "$1" = "-u" ]; then printf '%s\n' "$FAKE_ID_UID"; else exec /usr/bin/id "$@"; fi
|
||||
`)
|
||||
writeExecutable(t, filepath.Join(bin, "mktemp"), `#!/bin/sh
|
||||
path=$(/usr/bin/mktemp "$@") || exit 1
|
||||
printf '%s\n' "$path" >> "$FAKE_ROOT/private-directories"
|
||||
printf '%s\n' "$path"
|
||||
`)
|
||||
return root, []string{"PATH=" + bin + ":" + os.Getenv("PATH"), "FAKE_ROOT=" + root, "FAKE_ID_UID=0", "PULSE_SETUP_TOKEN=", "PULSE_TOKEN="}
|
||||
}
|
||||
|
||||
const recordingInstaller = `#!/bin/bash
|
||||
set -eu
|
||||
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
|
||||
if [[ " $* " == *" --preflight-only "* ]]; then
|
||||
printf preflight > "$FAKE_ROOT/preflight"
|
||||
exit "${FAKE_PREFLIGHT_EXIT:-0}"
|
||||
fi
|
||||
printf '%s:%s' "${PULSE_TOKEN:-}" "${PULSE_SETUP_TOKEN:-}" > "$FAKE_ROOT/secret-env"
|
||||
token_file="${PULSE_SETUP_TOKEN_FILE:-}"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in --token-file) token_file="$2"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
[ -n "$token_file" ]
|
||||
[ "$(stat -c %a "$token_file")" = 600 ]
|
||||
[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ]
|
||||
[ "$(stat -c %u "$token_file")" = "$(stat -c %u "$(dirname "$token_file")")" ]
|
||||
printf %s "$token_file" > "$FAKE_ROOT/token-path"
|
||||
cat "$token_file" > "$FAKE_ROOT/captured-token"
|
||||
exit "${FAKE_INSTALL_EXIT:-0}"
|
||||
`
|
||||
|
||||
func TestPrivateBootstrapKeepsSecretsOutOfCommands(t *testing.T) {
|
||||
secret := "synthetic-secret-0123456789"
|
||||
artifact := BuildSetupScriptInstallArtifact("https://pulse.example/base", "pbs", "https://pbs.example:8007", "https://pulse.example/base", false, secret, 1900000000)
|
||||
if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, secret) {
|
||||
t.Fatal("credential-bearing download")
|
||||
}
|
||||
for name, command := range map[string]string{
|
||||
"setup": artifact.Command,
|
||||
"setup-with-env-alias": artifact.CommandWithEnv,
|
||||
"setup-without-env-alias": artifact.CommandWithoutEnv,
|
||||
"agent-pve": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pve", IncludeInstallType: true}),
|
||||
"agent-pbs": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pbs", IncludeInstallType: true}),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if strings.Contains(command, secret) || strings.ContainsAny(command, "\r\n") || strings.Contains(command, "PULSE_SETUP_TOKEN=") || strings.Contains(command, "--token '") || strings.Contains(command, "| bash") {
|
||||
t.Fatal("copied command exposes a credential or executes a partial fetch")
|
||||
}
|
||||
if !strings.Contains(command, "read -r -s -p") || !strings.Contains(command, "sudo bash -c") || !strings.Contains(command, " -o \"$install_script\"") {
|
||||
t.Fatal("missing private prompt, sudo or complete download")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) {
|
||||
for _, kind := range []string{"agent", "setup"} {
|
||||
for _, uid := range []string{"0", "1000"} {
|
||||
t.Run(kind+"_uid_"+uid, func(t *testing.T) {
|
||||
root, env := bootstrapFixture(t, recordingInstaller)
|
||||
env = append(env, "FAKE_ID_UID="+uid)
|
||||
token := strings.Repeat("a", 32)
|
||||
command := BuildSetupScriptCommand("https://pulse.example/api/setup-script?type=pbs", token)
|
||||
if kind == "agent" {
|
||||
command = BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pbs", IncludeInstallType: true})
|
||||
}
|
||||
out, err := runBootstrap(t, command, token, env, true)
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v\n%s", err, out)
|
||||
}
|
||||
captured, err := os.ReadFile(filepath.Join(root, "captured-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(captured) != token {
|
||||
t.Fatal("private-file token differs")
|
||||
}
|
||||
args, _ := os.ReadFile(filepath.Join(root, "argv"))
|
||||
secretEnv, _ := os.ReadFile(filepath.Join(root, "secret-env"))
|
||||
if strings.Contains(string(args), token) || strings.Contains(string(out), token) || string(secretEnv) != ":" {
|
||||
t.Fatal("token leaked into argv, environment or terminal output")
|
||||
}
|
||||
for _, record := range []string{"token-path", "download-path"} {
|
||||
path, err := os.ReadFile(filepath.Join(root, record))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(string(path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("handoff remains: %s", record)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(string(path))); !os.IsNotExist(err) {
|
||||
t.Fatalf("handoff directory remains: %s", record)
|
||||
}
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(root, "preflight"))
|
||||
if kind == "agent" && err != nil {
|
||||
t.Fatal("agent preflight omitted")
|
||||
}
|
||||
if kind == "setup" && !os.IsNotExist(err) {
|
||||
t.Fatal("native setup ran agent preflight")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateBootstrapFailsBeforeInstallAndCleansHandoffs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env string
|
||||
tty bool
|
||||
input string
|
||||
}{
|
||||
{"no-terminal", "", false, ""}, {"blank-token", "", true, ""}, {"interrupt", "", true, "\x03"},
|
||||
{"partial-download", "FAKE_CURL_EXIT=22", false, ""}, {"wrong-installer-or-preflight", "FAKE_PREFLIGHT_EXIT=44", false, ""},
|
||||
{"sudo-refusal", "FAKE_SUDO_EXIT=1", false, ""}, {"install-failure", "FAKE_INSTALL_EXIT=9", true, strings.Repeat("b", 32)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root, env := bootstrapFixture(t, recordingInstaller)
|
||||
env = append(env, tc.env)
|
||||
if tc.name == "sudo-refusal" {
|
||||
env = append(env, "FAKE_ID_UID=1000")
|
||||
}
|
||||
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: "synthetic-not-in-command", InstallType: "pve", IncludeInstallType: true})
|
||||
out, err := runBootstrap(t, command, tc.input, env, tc.tty)
|
||||
if err == nil {
|
||||
t.Fatalf("failure appeared successful: %s", out)
|
||||
}
|
||||
if tc.input != "" && tc.input != "\x03" && strings.Contains(string(out), tc.input) {
|
||||
t.Fatal("token echoed on failure")
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(root, "captured-token"))
|
||||
if tc.name != "install-failure" && !os.IsNotExist(err) {
|
||||
t.Fatal("installer ran before prerequisite succeeded")
|
||||
}
|
||||
directories, _ := os.ReadFile(filepath.Join(root, "private-directories"))
|
||||
for _, path := range strings.Fields(string(directories)) {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("failure left a private bootstrap directory")
|
||||
}
|
||||
}
|
||||
|
||||
for _, record := range []string{"download-path", "token-path"} {
|
||||
p, err := os.ReadFile(filepath.Join(root, record))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(string(p))); !os.IsNotExist(err) {
|
||||
t.Fatalf("failure left %s directory", record)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupPrivateFileRejectsUnsafeInputsBeforeNativeMutation(t *testing.T) {
|
||||
for _, tc := range []string{"valid", "public-mode", "public-parent", "symlink", "oversized", "malformed", "missing", "directory"} {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Chmod(root, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(root, "token")
|
||||
token := strings.Repeat("c", 32)
|
||||
body := token
|
||||
if tc == "oversized" {
|
||||
body = strings.Repeat("c", 4097)
|
||||
}
|
||||
if tc == "malformed" {
|
||||
body = "do not echo this credential"
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch tc {
|
||||
case "public-mode":
|
||||
os.Chmod(file, 0644)
|
||||
case "public-parent":
|
||||
os.Chmod(root, 0755)
|
||||
case "symlink":
|
||||
file = filepath.Join(root, "link")
|
||||
if err := os.Symlink(filepath.Join(root, "token"), file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "missing":
|
||||
file = filepath.Join(root, "absent")
|
||||
case "directory":
|
||||
file = root
|
||||
}
|
||||
cmd := exec.Command("bash", "-c", setupTokenFilePrelude+`printf %s "$PULSE_SETUP_TOKEN" > "$FAKE_CAPTURE"`)
|
||||
capture := filepath.Join(root, "native-mutation-marker")
|
||||
cmd.Env = append(os.Environ(), "PULSE_SETUP_TOKEN_FILE="+file, "FAKE_CAPTURE="+capture)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if strings.Contains(string(out), body) {
|
||||
t.Fatal("invalid secret echoed")
|
||||
}
|
||||
if tc == "valid" {
|
||||
if err != nil {
|
||||
t.Fatalf("valid file: %v %s", err, out)
|
||||
}
|
||||
data, e := os.ReadFile(capture)
|
||||
if e != nil || string(data) != token {
|
||||
t.Fatal("private input lost")
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Fatal("unsafe file accepted")
|
||||
}
|
||||
if _, e := os.Stat(capture); !os.IsNotExist(e) {
|
||||
t.Fatal("native action followed unsafe input")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateBootstrapShellQuotesDataWithoutPuttingTokenInSource(t *testing.T) {
|
||||
root, env := bootstrapFixture(t, recordingInstaller)
|
||||
injected := filepath.Join(root, "must-not-exist")
|
||||
base := `https://pulse.example/' ; touch ` + injected + ` ; #`
|
||||
token := `tok'en-with-shell-' ; touch ` + injected
|
||||
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: base, Token: token, InstallType: "pbs", IncludeInstallType: true})
|
||||
if strings.Contains(command, token) {
|
||||
t.Fatal("credential interpolated")
|
||||
}
|
||||
out, err := runBootstrap(t, command, token, env, true)
|
||||
if err != nil {
|
||||
t.Fatalf("escaped bootstrap failed: %v %s", err, out)
|
||||
}
|
||||
args, err := os.ReadFile(filepath.Join(root, "argv"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(args), "--url\n"+base+"\n") || !strings.Contains(string(args), "--proxmox-type\npbs\n") {
|
||||
t.Fatal("quoted arguments changed")
|
||||
}
|
||||
if _, err := os.Stat(injected); !os.IsNotExist(err) {
|
||||
t.Fatal("argument was executed as shell source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateBootstrapInteractiveHistoryContainsCommandButNotToken(t *testing.T) {
|
||||
root, env := bootstrapFixture(t, recordingInstaller)
|
||||
token := strings.Repeat("d", 32)
|
||||
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pve", IncludeInstallType: true})
|
||||
history := filepath.Join(root, "shell-history")
|
||||
payload, err := json.Marshal(map[string]string{"command": command, "input": token, "history": history})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("python3", "-c", bootstrapPTYRunner)
|
||||
cmd.Stdin = strings.NewReader(string(payload))
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("interactive bootstrap: %v %s", err, out)
|
||||
}
|
||||
hist, err := os.ReadFile(history)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(hist), command) {
|
||||
t.Fatal("history control did not record the copied command")
|
||||
}
|
||||
if strings.Contains(string(hist), token) || strings.Contains(string(out), token) {
|
||||
t.Fatal("silent input entered shell history or terminal output")
|
||||
}
|
||||
}
|
||||
|
||||
// The public server installer is not the telemetry installer. If it is served
|
||||
// accidentally at the agent path, its real parser rejects --url before token
|
||||
// input or privileged installation; a complete but wrong fetch is not success.
|
||||
func TestPrivateAgentBootstrapRejectsActualServerInstaller(t *testing.T) {
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "..", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, env := bootstrapFixture(t, string(source))
|
||||
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: "synthetic-token", InstallType: "pve", IncludeInstallType: true})
|
||||
out, err := runBootstrap(t, command, "", env, false)
|
||||
if err == nil || !strings.Contains(string(out), "Unknown option: --url") {
|
||||
t.Fatal("wrong installer was not rejected by its real parser")
|
||||
}
|
||||
dirs, _ := os.ReadFile(filepath.Join(root, "private-directories"))
|
||||
for _, path := range strings.Fields(string(dirs)) {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("wrong-installer failure left a private directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
// Real TLS/curl, issuance, downloaded native PBS script, private handoff and
|
||||
// registration. Only the appliance CLI and EUID guard are modeled: Core's
|
||||
// ordinary proof guest is deliberately not a system-install/root capability.
|
||||
func TestPrivatePBSBootstrapPipelineTLSAndRegistration(t *testing.T) {
|
||||
curlPath, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
t.Fatal("curl required for the TLS bootstrap acceptance test")
|
||||
}
|
||||
for _, status := range []int{200, 401, 403, 503} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
root, env := bootstrapFixture(t, "")
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
var registration AutoRegisterRequest
|
||||
var registrationMu sync.Mutex
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/setup-script" {
|
||||
if r.URL.Query().Has("setup_token") {
|
||||
t.Error("download URL contains setup token")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSetupScript(rr, r)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("download status %d", rr.Code)
|
||||
}
|
||||
for k, values := range rr.Header() {
|
||||
w.Header()[k] = values
|
||||
}
|
||||
// Preserve every operation except the unavailable actual root
|
||||
// identity check. All PBS CLI calls below are local fixtures.
|
||||
script := strings.Replace(rr.Body.String(), `if [ "$EUID" -ne 0 ]; then`, `if false; then`, 1)
|
||||
w.Write([]byte(script))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/auto-register" {
|
||||
var input AutoRegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
registrationMu.Lock()
|
||||
registration = input
|
||||
registrationMu.Unlock()
|
||||
body, _ := json.Marshal(input)
|
||||
r.Body = http.NoBody
|
||||
if status != 200 {
|
||||
w.WriteHeader(status)
|
||||
// An adverse server response must not turn the request's
|
||||
// credential into diagnostic output on the terminal.
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
handler.HandleAutoRegister(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg.PublicURL = server.URL
|
||||
ca := filepath.Join(root, "ca.pem")
|
||||
if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pbs","host":"`+server.URL+`"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSetupScriptURL(rr, request)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("issuance status %d", rr.Code)
|
||||
}
|
||||
var artifact SetupScriptInstallArtifact
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &artifact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(artifact.Command, artifact.SetupToken) || artifact.DownloadURL != artifact.URL {
|
||||
t.Fatal("unsafe issued artifact")
|
||||
}
|
||||
// Record only process argument/secret-export presence; never dump
|
||||
// the environment, credential file, or registration body to logs.
|
||||
writeExecutable(t, filepath.Join(root, "bin", "curl"), "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"$FAKE_ROOT/argv\"\nexec "+posixShellQuote(curlPath)+" \"$@\"\n")
|
||||
writeExecutable(t, filepath.Join(root, "bin", "proxmox-backup-manager"), `#!/bin/sh
|
||||
printf '%s\n' "$@" >> "$FAKE_ROOT/native-argv"
|
||||
case "$*" in
|
||||
"user generate-token "*) printf '%s\n' '{"value":"synthetic-native-PBS-secret"}' ;;
|
||||
esac
|
||||
`)
|
||||
if status == 200 {
|
||||
out, err := runBootstrap(t, artifact.Command, "", env, false)
|
||||
if err == nil {
|
||||
t.Fatal("untrusted TLS was accepted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "native-argv")); !os.IsNotExist(err) {
|
||||
t.Fatal("native mutation preceded trusted download")
|
||||
}
|
||||
if strings.Contains(string(out), artifact.SetupToken) {
|
||||
t.Fatal("setup credential leaked on TLS failure")
|
||||
}
|
||||
}
|
||||
env = append(env, "CURL_CA_BUNDLE="+ca)
|
||||
out, err := runBootstrap(t, artifact.Command, artifact.SetupToken, env, true)
|
||||
if status == 200 && err != nil {
|
||||
t.Fatalf("trusted bootstrap: %v\n%s", err, out)
|
||||
}
|
||||
if status != 200 && err == nil {
|
||||
t.Fatalf("HTTP %d appeared successful", status)
|
||||
}
|
||||
args, _ := os.ReadFile(filepath.Join(root, "argv"))
|
||||
if strings.Contains(string(args), artifact.SetupToken) || strings.Contains(string(args), "synthetic-native-PBS-secret") || strings.Contains(string(out), artifact.SetupToken) {
|
||||
t.Fatal("credential leaked through args or failed-response output")
|
||||
}
|
||||
registrationMu.Lock()
|
||||
defer registrationMu.Unlock()
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
if registration.AuthToken != artifact.SetupToken || registration.TokenValue != "synthetic-native-PBS-secret" || registration.Type != "pbs" || registration.Host != server.URL {
|
||||
t.Fatal("registration lost credential, host or product binding")
|
||||
}
|
||||
if status == 200 && len(cfg.PBSInstances) != 1 {
|
||||
t.Fatal("registration did not create one PBS source")
|
||||
}
|
||||
if status != 200 && len(cfg.PBSInstances) != 0 {
|
||||
t.Fatal("failed registration created a source")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,11 @@ type SetupScriptInstallArtifact struct {
|
||||
|
||||
type setupScriptInstallArtifact = SetupScriptInstallArtifact
|
||||
|
||||
func BuildSetupScriptCommand(scriptURL string, token string) string {
|
||||
curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | "
|
||||
bashCommand := "bash"
|
||||
sudoCommand := "sudo bash"
|
||||
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
|
||||
envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " "
|
||||
bashCommand = envPrefix + bashCommand
|
||||
sudoCommand = "sudo env " + envPrefix + "bash"
|
||||
}
|
||||
|
||||
return curlCommand +
|
||||
`{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand +
|
||||
`; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand +
|
||||
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
|
||||
// The legacy token argument is intentionally not interpolated. All command
|
||||
// aliases now prompt privately; the separate SetupToken field is the reveal.
|
||||
func BuildSetupScriptCommand(scriptURL string, _ string) string {
|
||||
return privateBootstrapCommand(scriptURL, "-fsSL", "",
|
||||
`PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1";`, true, "Pulse setup token")
|
||||
}
|
||||
|
||||
func BuildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string {
|
||||
@@ -53,20 +44,9 @@ func BuildSetupScriptURL(baseURL string, installType string, host string, pulseU
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/api/setup-script?" + query.Encode()
|
||||
}
|
||||
|
||||
func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string {
|
||||
downloadURL := BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
|
||||
trimmedToken := strings.TrimSpace(setupToken)
|
||||
if trimmedToken == "" {
|
||||
return downloadURL
|
||||
}
|
||||
parsed, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return downloadURL
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("setup_token", trimmedToken)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
// Downloads contain no credential, including in the request URL or script.
|
||||
func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, _ string) string {
|
||||
return BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
|
||||
}
|
||||
|
||||
func BuildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) SetupScriptInstallArtifact {
|
||||
|
||||
@@ -33,11 +33,54 @@ func DeriveSetupScriptServerName(serverHost string) string {
|
||||
return strings.Split(trimmedHost, ":")[0]
|
||||
}
|
||||
|
||||
// setupTokenFilePrelude reads only a bounded private credential, never an
|
||||
// argument or exported secret. Legacy downloaded scripts with an embedded
|
||||
// query token remain readable, but new artifacts do not generate such URLs.
|
||||
const setupTokenFilePrelude = `set +xv
|
||||
export -n PULSE_SETUP_TOKEN 2>/dev/null || true
|
||||
if [ -n "${PULSE_SETUP_TOKEN_FILE:-}" ]; then
|
||||
setup_token_file="$PULSE_SETUP_TOKEN_FILE"
|
||||
if [ ! -f "$setup_token_file" ] || [ -L "$setup_token_file" ] || [ ! -r "$setup_token_file" ]; then
|
||||
echo "Setup token file must be a readable private regular file." >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_token_mode=$(stat -c %a "$setup_token_file" 2>/dev/null || stat -f %Lp "$setup_token_file")
|
||||
setup_token_owner=$(stat -c %u "$setup_token_file" 2>/dev/null || stat -f %u "$setup_token_file")
|
||||
setup_token_parent=$(dirname -- "$setup_token_file")
|
||||
if [ -L "$setup_token_parent" ] || [ "$(stat -c %a "$setup_token_parent" 2>/dev/null || stat -f %Lp "$setup_token_parent")" != "700" ] || [ "$(stat -c %u "$setup_token_parent" 2>/dev/null || stat -f %u "$setup_token_parent")" != "$EUID" ]; then
|
||||
echo "Setup token file requires a private directory owned by the current user." >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_token_size=$(wc -c < "$setup_token_file")
|
||||
if [ "$setup_token_mode" != "600" ] || [ "$setup_token_owner" != "$EUID" ] || [ "$setup_token_size" -gt 4096 ]; then
|
||||
echo "Setup token file must be owned by the current user, mode 0600, and at most 4096 bytes." >&2
|
||||
exit 1
|
||||
fi
|
||||
PULSE_SETUP_TOKEN=$(cat -- "$setup_token_file")
|
||||
if [[ ! "$PULSE_SETUP_TOKEN" =~ ^[a-fA-F0-9]{32,128}$ ]]; then
|
||||
echo "Setup token file contains an invalid setup token." >&2
|
||||
exit 1
|
||||
fi
|
||||
unset setup_token_file setup_token_mode setup_token_owner setup_token_size setup_token_parent
|
||||
fi
|
||||
`
|
||||
|
||||
func RenderSetupScript(serverType string, ctx SetupScriptRenderContext) string {
|
||||
var script string
|
||||
if strings.TrimSpace(serverType) == "pve" {
|
||||
return renderPVESetupScript(ctx)
|
||||
script = renderPVESetupScript(ctx)
|
||||
} else {
|
||||
script = renderPBSSetupScript(ctx)
|
||||
}
|
||||
return renderPBSSetupScript(ctx)
|
||||
script = strings.Replace(script, "#!/bin/bash\n", "#!/bin/bash\n"+setupTokenFilePrelude, 1)
|
||||
// A rejected setup credential or failed attempted registration is not a
|
||||
// successful bootstrap. PVE's explicit Audit/Repair exit stays separate.
|
||||
return script + `
|
||||
if [ "${SETUP_TOKEN_INVALID:-false}" = true ] || [ "${TOKEN_READY:-false}" != true ] || { [ -n "${PULSE_SETUP_TOKEN:-}" ] && [ "${AUTO_REG_SUCCESS:-false}" != true ]; }; then
|
||||
exit 1
|
||||
fi
|
||||
`
|
||||
|
||||
}
|
||||
|
||||
func renderPVESetupScript(ctx SetupScriptRenderContext) string {
|
||||
@@ -488,7 +531,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then
|
||||
UNREGISTER_RC=$?
|
||||
if [ "$UNREGISTER_RC" -ne 0 ]; then
|
||||
echo " ⚠️ Pulse server teardown request failed."
|
||||
echo " Response: $UNREGISTER_RESPONSE"
|
||||
echo " Check the Pulse connection and retry; response details are not printed."
|
||||
echo " Remove the node from Pulse manually if it remains listed."
|
||||
elif echo "$UNREGISTER_RESPONSE" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"'; then
|
||||
if echo "$UNREGISTER_RESPONSE" | grep -Eq '"removed"[[:space:]]*:[[:space:]]*true'; then
|
||||
@@ -498,7 +541,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Pulse server teardown did not confirm success."
|
||||
echo " Response: $UNREGISTER_RESPONSE"
|
||||
echo " Check the Pulse connection and retry; response details are not printed."
|
||||
echo " Remove the node from Pulse manually if it remains listed."
|
||||
fi
|
||||
else
|
||||
@@ -684,8 +727,8 @@ create_pve_token() {
|
||||
}
|
||||
|
||||
smoke_test_pve_token() {
|
||||
if SMOKE_OUTPUT=$(curl -kfsS --retry 2 --retry-delay 1 \
|
||||
-H "Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE" \
|
||||
if SMOKE_OUTPUT=$(printf 'Authorization: PVEAPIToken=%%s=%%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl -kfsS --retry 2 --retry-delay 1 \
|
||||
-H @- \
|
||||
"${HOST_URL%%/}/api2/json/nodes" 2>&1); then
|
||||
SMOKE_RC=0
|
||||
else
|
||||
@@ -710,13 +753,13 @@ attempt_auto_registration() {
|
||||
if [ -t 0 ]; then
|
||||
printf "Pulse setup token: "
|
||||
if command -v stty >/dev/null 2>&1; then stty -echo; fi
|
||||
IFS= read -r PULSE_SETUP_TOKEN
|
||||
IFS= read -r -s PULSE_SETUP_TOKEN
|
||||
if command -v stty >/dev/null 2>&1; then stty echo; fi
|
||||
printf "\n"
|
||||
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf "Pulse setup token: " >/dev/tty
|
||||
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
|
||||
IFS= read -r PULSE_SETUP_TOKEN </dev/tty || true
|
||||
IFS= read -r -s PULSE_SETUP_TOKEN </dev/tty || true
|
||||
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
|
||||
printf "\n" >/dev/tty
|
||||
fi
|
||||
@@ -753,13 +796,13 @@ attempt_auto_registration() {
|
||||
AUTO_REG_SUCCESS=false
|
||||
if [ "$REGISTER_RC" -ne 0 ]; then
|
||||
echo "⚠️ Auto-registration request failed before success confirmation."
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
|
||||
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
|
||||
SETUP_TOKEN_INVALID=true
|
||||
echo "Error: Auto-registration failed - authentication required"
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "The provided Pulse setup token was invalid or expired"
|
||||
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
|
||||
@@ -769,7 +812,7 @@ attempt_auto_registration() {
|
||||
echo ""
|
||||
else
|
||||
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
|
||||
fi
|
||||
@@ -1682,13 +1725,13 @@ else
|
||||
if [ -t 0 ]; then
|
||||
printf "Pulse setup token: "
|
||||
if command -v stty >/dev/null 2>&1; then stty -echo; fi
|
||||
IFS= read -r PULSE_SETUP_TOKEN
|
||||
IFS= read -r -s PULSE_SETUP_TOKEN
|
||||
if command -v stty >/dev/null 2>&1; then stty echo; fi
|
||||
printf "\n"
|
||||
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf "Pulse setup token: " >/dev/tty
|
||||
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
|
||||
IFS= read -r PULSE_SETUP_TOKEN </dev/tty || true
|
||||
IFS= read -r -s PULSE_SETUP_TOKEN </dev/tty || true
|
||||
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
|
||||
printf "\n" >/dev/tty
|
||||
fi
|
||||
@@ -1731,13 +1774,13 @@ else
|
||||
:
|
||||
elif [ "$REGISTER_RC" -ne 0 ]; then
|
||||
echo "⚠️ Auto-registration request failed before success confirmation."
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
|
||||
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
|
||||
SETUP_TOKEN_INVALID=true
|
||||
echo "Error: Auto-registration failed - authentication required"
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "The provided Pulse setup token was invalid or expired"
|
||||
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
|
||||
@@ -1746,7 +1789,7 @@ else
|
||||
echo "Successfully registered with Pulse monitoring."
|
||||
else
|
||||
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
|
||||
echo " Response: $REGISTER_RESPONSE"
|
||||
echo " Registration failed; check the connection and setup-token expiry in Pulse."
|
||||
echo ""
|
||||
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
|
||||
fi
|
||||
@@ -1761,7 +1804,7 @@ proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
||||
proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"
|
||||
|
||||
echo ""
|
||||
echo "✅ Setup complete!"
|
||||
echo "Setup result:"
|
||||
if [ "$AUTO_REG_SUCCESS" = true ]; then
|
||||
echo "Successfully registered with Pulse monitoring."
|
||||
echo "Data will appear in your dashboard within 10 seconds."
|
||||
|
||||
@@ -1504,7 +1504,8 @@ fi`
|
||||
`pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin`,
|
||||
`pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin`,
|
||||
`smoke_test_pve_token() {`,
|
||||
`Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`,
|
||||
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl`,
|
||||
`-H @-`,
|
||||
`${HOST_URL%/}/api2/json/nodes`,
|
||||
`if smoke_test_pve_token; then`,
|
||||
} {
|
||||
@@ -6907,18 +6908,13 @@ func TestContract_DiagnosticsDockerPrepareTokenPreservesExplicitWorkloadOnlyMode
|
||||
func TestContract_SetupScriptURLCommandUsesFailFastQuotedTransport(t *testing.T) {
|
||||
url := "https://pulse.example.com/api/setup-script?type=pve&host=pve1.local"
|
||||
got := buildSetupScriptCommand(url, "token-123")
|
||||
|
||||
if !strings.Contains(got, "curl -fsSL "+posixShellQuote(url)+" | ") {
|
||||
t.Fatalf("setup-script command missing canonical fail-fast transport: %s", got)
|
||||
for _, required := range []string{"curl -fsSL " + posixShellQuote(url) + ` -o "$install_script"`, "sudo bash -c", "PULSE_SETUP_TOKEN_FILE="} {
|
||||
if !strings.Contains(got, required) {
|
||||
t.Fatalf("missing secure bootstrap fragment %s", required)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) {
|
||||
t.Fatalf("setup-script command missing direct-root execution path: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) {
|
||||
t.Fatalf("setup-script command missing sudo execution path: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "curl -sSL ") {
|
||||
t.Fatalf("setup-script command preserved stale non-fail-fast curl transport: %s", got)
|
||||
if strings.Contains(got, "token-123") || strings.Contains(got, "PULSE_SETUP_TOKEN=") || strings.Contains(got, "| bash") {
|
||||
t.Fatal("bootstrap credential or partial script can reach the copied command")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6942,12 +6938,10 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
|
||||
}
|
||||
|
||||
script := rec.Body.String()
|
||||
if !strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | `) {
|
||||
t.Fatalf("setup script missing canonical bootstrap command owner: %s", script)
|
||||
}
|
||||
if strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`) {
|
||||
t.Fatalf("setup script bootstrap command should defer setup token to runtime hydration, got: %s", script)
|
||||
if !strings.Contains(script, "PULSE_BOOTSTRAP_COMMAND_WITH_ENV="+posixShellQuote(buildSetupScriptCommand(buildSetupScriptURL("http://sentinel-url:7656", "pve", "http://sentinel-host:8006", "http://sentinel-url:7656", false), ""))) {
|
||||
t.Fatal("setup script must use the shared credential-free retry command")
|
||||
}
|
||||
|
||||
if !strings.Contains(script, `echo " $PULSE_BOOTSTRAP_COMMAND_WITH_ENV"`) {
|
||||
t.Fatalf("setup script missing bootstrap-command retry guidance: %s", script)
|
||||
}
|
||||
@@ -10250,7 +10244,7 @@ func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T
|
||||
IncludeInstallType: true,
|
||||
})
|
||||
|
||||
if !strings.Contains(got, "--url "+posixShellQuote("http://pulse.example.com:7655")) {
|
||||
if !strings.Contains(got, "http://pulse.example.com:7655") {
|
||||
t.Fatalf("install command missing canonical base URL: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--insecure") {
|
||||
@@ -10272,13 +10266,13 @@ func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing
|
||||
if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then`) {
|
||||
t.Fatalf("install command missing root-or-sudo wrapper: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `sudo bash -s --`) {
|
||||
if !strings.Contains(got, `sudo bash -c`) {
|
||||
t.Fatalf("install command missing sudo fallback: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) {
|
||||
if !strings.Contains(got, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) {
|
||||
t.Fatalf("install command missing root-owned sudo token bootstrap: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `rm -rf -- "$token_dir"`) {
|
||||
if !strings.Contains(got, `rmdir -- "$token_dir"`) {
|
||||
t.Fatalf("install command missing ephemeral token cleanup: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -10294,7 +10288,7 @@ func TestContract_OptionalAuthProxmoxInstallCommandOmitsToken(t *testing.T) {
|
||||
if strings.Contains(got, "--token") {
|
||||
t.Fatalf("optional-auth install command preserved token flag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com")) {
|
||||
if !strings.Contains(got, "https://pulse.example.com") {
|
||||
t.Fatalf("optional-auth install command missing canonical base URL: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -10310,7 +10304,7 @@ func TestContract_ProxmoxInstallCommandNormalizesTrailingSlashBaseURL(t *testing
|
||||
if !strings.Contains(got, posixShellQuote("https://pulse.example.com/base/install.sh")) {
|
||||
t.Fatalf("install command missing normalized install script URL: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com/base")) {
|
||||
if !strings.Contains(got, "https://pulse.example.com/base") {
|
||||
t.Fatalf("install command missing normalized base URL: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `--token-file "$token_file"`) {
|
||||
@@ -11206,40 +11200,26 @@ func TestContract_ResetFirstRunSecurityClearsEnvBackedStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContract_SetupScriptURLResponseJSONSnapshot(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"type": "pve",
|
||||
"host": "https://pve.local:8006",
|
||||
"url": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve",
|
||||
"downloadURL": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&setup_token=setup-token-123&type=pve",
|
||||
"scriptFileName": "pulse-setup-pve.sh",
|
||||
"command": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
|
||||
"commandWithEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
|
||||
"commandWithoutEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo >/dev/null 2>&1; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
|
||||
"expires": int64(1900000000),
|
||||
"setupToken": "setup-token-123",
|
||||
"tokenHint": "set…123",
|
||||
}
|
||||
|
||||
got, err := json.Marshal(payload)
|
||||
artifact := buildSetupScriptInstallArtifact("https://pulse.example", "pve", "https://pve.local:8006", "https://pulse.example", false, "setup-token-123", 1900000000)
|
||||
payload, err := json.Marshal(artifact)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal setup-script-url response: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(payload, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"command", "commandWithEnv", "commandWithoutEnv"} {
|
||||
command, ok := got[field].(string)
|
||||
if !ok || command != artifact.Command || strings.Contains(command, artifact.SetupToken) || !strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") {
|
||||
t.Fatalf("unsafe %s", field)
|
||||
}
|
||||
delete(got, field)
|
||||
}
|
||||
want := map[string]any{"type": "pve", "host": "https://pve.local:8006", "url": artifact.URL, "downloadURL": artifact.URL, "scriptFileName": "pulse-setup-pve.sh", "expires": float64(1900000000), "setupToken": "setup-token-123", "tokenHint": "set…123"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("bootstrap envelope = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
const want = `{
|
||||
"command":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
|
||||
"commandWithEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
|
||||
"commandWithoutEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
|
||||
"downloadURL":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026setup_token=setup-token-123\u0026type=pve",
|
||||
"expires":1900000000,
|
||||
"host":"https://pve.local:8006",
|
||||
"scriptFileName":"pulse-setup-pve.sh",
|
||||
"setupToken":"setup-token-123",
|
||||
"tokenHint":"set…123",
|
||||
"type":"pve",
|
||||
"url":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve"
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, got, want)
|
||||
}
|
||||
|
||||
func TestContract_PublicSecurityStatusIncludesDemoPresentationPolicy(t *testing.T) {
|
||||
@@ -23296,8 +23276,8 @@ func TestContract_RequestOriginCannotRetargetTokenBearingCommands(t *testing.T)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Token == "" || !strings.Contains(response.Command, response.Token) {
|
||||
t.Fatalf("fresh token was not carried by install command: %#v", response)
|
||||
if response.Token == "" || strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, `--token-file "$token_file"`) {
|
||||
t.Fatal("fresh credential must be returned separately and entered privately")
|
||||
}
|
||||
if !strings.Contains(response.Command, tc.wantBaseURL+"/install.sh") ||
|
||||
!strings.Contains(response.Command, "--url "+posixShellQuote(tc.wantBaseURL)) {
|
||||
@@ -23347,9 +23327,10 @@ func TestContract_RequestOriginCannotRetargetTokenBearingCommands(t *testing.T)
|
||||
t.Fatalf("decode setup artifact: %v", err)
|
||||
}
|
||||
if artifact.SetupToken == "" ||
|
||||
!strings.Contains(artifact.DownloadURL, artifact.SetupToken) ||
|
||||
!strings.Contains(artifact.Command, artifact.SetupToken) {
|
||||
t.Fatalf("setup token was not carried by canonical artifact: %#v", artifact)
|
||||
strings.Contains(artifact.DownloadURL, artifact.SetupToken) ||
|
||||
strings.Contains(artifact.Command, artifact.SetupToken) ||
|
||||
artifact.DownloadURL != artifact.URL || !strings.Contains(artifact.Command, "PULSE_SETUP_TOKEN_FILE=") {
|
||||
t.Fatal("canonical artifact must reveal the setup credential separately from commands and URLs")
|
||||
}
|
||||
if !strings.HasPrefix(artifact.URL, autoDetectedURL+"/api/setup-script?") ||
|
||||
!strings.HasPrefix(artifact.DownloadURL, autoDetectedURL+"/api/setup-script?") {
|
||||
@@ -24111,7 +24092,7 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode install response: %v", err)
|
||||
}
|
||||
if response.Token == "" || !strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, wantBaseURL+"/install.sh") {
|
||||
if response.Token == "" || strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, `--token-file "$token_file"`) || !strings.Contains(response.Command, wantBaseURL+"/install.sh") {
|
||||
t.Fatalf("install response did not bind fresh token to configured URL %q: %#v", wantBaseURL, response)
|
||||
}
|
||||
if strings.Contains(response.Command, "attacker") || len(cfg.APITokens) != 2 {
|
||||
@@ -24143,8 +24124,8 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) {
|
||||
if downloadRec.Code != http.StatusOK {
|
||||
t.Fatalf("setup script status = %d, want %d: %s", downloadRec.Code, http.StatusOK, downloadRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(downloadRec.Body.String(), wantBaseURL) || !strings.Contains(downloadRec.Body.String(), artifact.SetupToken) || strings.Contains(downloadRec.Body.String(), "attacker") {
|
||||
t.Fatalf("rendered setup script did not preserve configured token target")
|
||||
if !strings.Contains(downloadRec.Body.String(), wantBaseURL) || strings.Contains(downloadRec.Body.String(), artifact.SetupToken) || !strings.Contains(downloadRec.Body.String(), "PULSE_SETUP_TOKEN_FILE") || strings.Contains(downloadRec.Body.String(), "attacker") {
|
||||
t.Fatal("download must preserve the configured target and private-input contract without embedding a setup credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25096,8 +25077,8 @@ func TestContract_HostedRuntimeAgentInstallCommandCarriesToken(t *testing.T) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
t.Fatalf("%s: hosted install payload has no token: %s", body, rec.Body.String())
|
||||
}
|
||||
if command, _ := payload["command"].(string); command != "" && !strings.Contains(command, token) {
|
||||
t.Fatalf("%s: install command does not carry the minted token", body)
|
||||
if command, _ := payload["command"].(string); command != "" && (strings.Contains(command, token) || !strings.Contains(command, `--token-file "$token_file"`)) {
|
||||
t.Fatalf("%s: minted credential must be entered privately, not embedded", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes
|
||||
|
||||
require.Equal(t, orgID, resp.OrgID)
|
||||
require.Contains(t, resp.Command, "https://cloud.example.com/install.sh")
|
||||
require.Contains(t, resp.Command, "printf %s "+posixShellQuote(resp.Token)+` > "$token_file"`)
|
||||
require.NotContains(t, resp.Command, resp.Token)
|
||||
require.Contains(t, resp.Command, `--token-file "$token_file"`)
|
||||
require.Contains(t, resp.Command, "--proxmox-type "+posixShellQuote("pbs"))
|
||||
require.Contains(t, resp.Command, "--proxmox-type "+strings.ReplaceAll(posixShellQuote("pbs"), "'", `'"'"'`))
|
||||
|
||||
require.Regexp(t, regexp.MustCompile(`^[a-f0-9]{64}$`), resp.Token)
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary(t *tes
|
||||
require.NotEmpty(t, result.Token)
|
||||
require.NotEmpty(t, result.TokenID)
|
||||
require.Contains(t, result.Command, "https://acme.cloud.example.com/install.sh")
|
||||
require.Contains(t, result.Command, "--proxmox-type "+posixShellQuote("pbs"))
|
||||
require.Contains(t, result.Command, "--proxmox-type "+strings.ReplaceAll(posixShellQuote("pbs"), "'", `'"'"'`))
|
||||
|
||||
tokens, err := persistence.LoadAPITokens()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -133,6 +133,12 @@ func validateSetupScriptCommand(command string, expectedScriptURL string, setupT
|
||||
if !strings.Contains(trimmedCommand, `elif command -v sudo >/dev/null 2>&1; then`) {
|
||||
return fmt.Errorf("command missing sudo path")
|
||||
}
|
||||
// New servers separate reveal from private input. Accept the old coherent
|
||||
// artifact only for rolling upgrades against an older server; never mix
|
||||
// its token-bearing URL with the new command format.
|
||||
if strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN_FILE=") && !strings.Contains(trimmedCommand, setupToken) && !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") {
|
||||
return nil
|
||||
}
|
||||
if tokenRequired {
|
||||
if !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") || !strings.Contains(trimmedCommand, setupToken) {
|
||||
return fmt.Errorf("command missing setup token transport")
|
||||
@@ -1494,7 +1500,7 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
expectedDownloadURL = parsedURL.String()
|
||||
}
|
||||
if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL {
|
||||
if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL && strings.TrimSpace(parsed.DownloadURL) != expectedScriptURL {
|
||||
return "", fmt.Errorf("setup token response downloadURL mismatch")
|
||||
}
|
||||
if strings.TrimSpace(parsed.TokenHint) == "" {
|
||||
@@ -1503,6 +1509,13 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct
|
||||
if strings.TrimSpace(parsed.TokenHint) == strings.TrimSpace(parsed.SetupToken) {
|
||||
return "", fmt.Errorf("setup token response tokenHint must mask setupToken")
|
||||
}
|
||||
modern := strings.TrimSpace(parsed.DownloadURL) == expectedScriptURL
|
||||
for _, command := range []string{parsed.Command, parsed.CommandWithEnv, parsed.CommandWithoutEnv} {
|
||||
if modern != strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") {
|
||||
return "", fmt.Errorf("setup-script command and download credential transports do not match")
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSetupScriptCommand(parsed.Command, expectedScriptURL, strings.TrimSpace(parsed.SetupToken), true); err != nil {
|
||||
return "", fmt.Errorf("setup token response command invalid: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -1910,3 +1912,47 @@ func TestRunAllFailsWhenEveryDetectedTypeFails(t *testing.T) {
|
||||
t.Fatalf("results = %#v, want none", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxmoxSetupFetchTokenAcceptsCoherentPrivateOrLegacyServerTransport(t *testing.T) {
|
||||
for _, product := range []string{"pve", "pbs"} {
|
||||
for _, mode := range []string{"private", "legacy", "mixed"} {
|
||||
t.Run(product+"_"+mode, func(t *testing.T) {
|
||||
base, host, token := "https://pulse.example", "https://node.example:8006", "setup-token-123"
|
||||
var artifact map[string]any
|
||||
if err := json.Unmarshal([]byte(canonicalSetupScriptURLResponseJSON(base, product, host, token)), &artifact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "legacy" {
|
||||
command := `curl -fsSL '` + artifact["url"].(string) + `' -o "$install_script"; if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then :; fi; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$install_script"`
|
||||
artifact["downloadURL"] = artifact["url"]
|
||||
artifact["command"] = command
|
||||
artifact["commandWithEnv"] = command
|
||||
artifact["commandWithoutEnv"] = command
|
||||
if mode == "mixed" {
|
||||
artifact["commandWithEnv"] = canonicalSetupScriptCommand(artifact["url"].(string), token)
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(artifact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &ProxmoxSetup{pulseURL: base, httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/api/setup-script-url" {
|
||||
t.Errorf("unexpected endpoint %s", req.URL.Path)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})}}
|
||||
got, err := p.fetchSetupToken(context.Background(), proxmoxProductType(product), host)
|
||||
if mode == "mixed" {
|
||||
if err == nil {
|
||||
t.Fatal("mixed private/legacy credential transport accepted")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != token {
|
||||
t.Fatalf("%s metadata refused: %v", mode, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package mock
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mockmodel"
|
||||
@@ -218,6 +219,36 @@ type MetricSampler struct {
|
||||
roles map[string]string
|
||||
}
|
||||
|
||||
// runtimeMetricSampler caches only derived, immutable roles. The graph data
|
||||
// version changes on both metric ticks and structural updates, so new series
|
||||
// observe the current canonical fixture while existing samplers remain bound
|
||||
// to their captured graph. Private graph/global persona updates cannot replace
|
||||
// this canonical snapshot.
|
||||
var runtimeMetricSampler struct {
|
||||
sync.Mutex
|
||||
version uint64
|
||||
sampler MetricSampler
|
||||
}
|
||||
|
||||
// CurrentMetricSampler avoids cloning the entire demo estate and resolving
|
||||
// every resource's persona for every chart series. Graph publication and the
|
||||
// version read share dataMu; the cached role map is never mutated after return.
|
||||
func CurrentMetricSampler() MetricSampler {
|
||||
dataMu.RLock()
|
||||
defer dataMu.RUnlock()
|
||||
if !enabled.Load() {
|
||||
return MetricSampler{}
|
||||
}
|
||||
runtimeMetricSampler.Lock()
|
||||
defer runtimeMetricSampler.Unlock()
|
||||
version := fixtureDataVersion.Load()
|
||||
if runtimeMetricSampler.sampler.roles == nil || runtimeMetricSampler.version != version {
|
||||
runtimeMetricSampler.sampler = NewMetricSampler(mockGraph)
|
||||
runtimeMetricSampler.version = version
|
||||
}
|
||||
return runtimeMetricSampler.sampler
|
||||
}
|
||||
|
||||
func NewMetricSampler(graph FixtureGraph) MetricSampler {
|
||||
return MetricSampler{roles: buildMetricRoleRegistry(graph)}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -89,3 +92,118 @@ func TestMetricSamplerRemainsBoundToFixtureGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the graph and its version coherent, just like runtime publication. Do
|
||||
// not restore an old version: existing readers may still hold its sampler.
|
||||
func installMetricSamplerGraphForTest(tb testing.TB, graph FixtureGraph) {
|
||||
tb.Helper()
|
||||
dataMu.Lock()
|
||||
previousGraph, previousEnabled := mockGraph, enabled.Load()
|
||||
mockGraph = graph
|
||||
enabled.Store(true)
|
||||
fixtureDataVersion.Add(1)
|
||||
dataMu.Unlock()
|
||||
tb.Cleanup(func() {
|
||||
dataMu.Lock()
|
||||
mockGraph = previousGraph
|
||||
enabled.Store(previousEnabled)
|
||||
fixtureDataVersion.Add(1)
|
||||
dataMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestCurrentMetricSamplerTracksGraphVersionWithoutGlobalPersonaLeak(t *testing.T) {
|
||||
graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}}
|
||||
installMetricSamplerGraphForTest(t, graph)
|
||||
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
|
||||
timestamps := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
|
||||
initial := CurrentMetricSampler()
|
||||
want := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps)
|
||||
if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
|
||||
t.Fatal("cached sampler differs from graph-bound reference")
|
||||
}
|
||||
previousRegistry := currentMetricRoleRegistry()
|
||||
t.Cleanup(func() { setMetricRoleRegistry(previousRegistry) })
|
||||
setMetricRoleRegistry(map[string]string{metricRoleRegistryKey("container", "neutral-155"): metricRoleDatabase})
|
||||
if !reflect.DeepEqual(CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
|
||||
t.Fatal("private/global persona update replaced canonical graph sampling")
|
||||
}
|
||||
if allocs := testing.AllocsPerRun(100, func() { _ = CurrentMetricSampler() }); allocs != 0 {
|
||||
t.Fatalf("warm sampler acquisition allocates %v times; graph must not be cloned per series", allocs)
|
||||
}
|
||||
dataMu.Lock()
|
||||
mockGraph.State.Containers[0].Name = "database-primary"
|
||||
fixtureDataVersion.Add(1)
|
||||
dataMu.Unlock()
|
||||
updated := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps)
|
||||
reference := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps)
|
||||
if !reflect.DeepEqual(updated, reference) || reflect.DeepEqual(updated, want) {
|
||||
t.Fatal("new graph version did not refresh its immutable persona snapshot")
|
||||
}
|
||||
if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
|
||||
t.Fatal("publishing a new sampler mutated an already-returned sampler")
|
||||
}
|
||||
enabled.Store(false)
|
||||
if got := CurrentMetricSampler(); got.roles != nil {
|
||||
t.Fatal("disabled mock mode retained canonical roles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentMetricSamplerConcurrentSeriesRemainSnapshotBound(t *testing.T) {
|
||||
graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}}
|
||||
backup := NewMetricSampler(graph)
|
||||
graph.State.Containers[0].Name = "database-primary"
|
||||
database := NewMetricSampler(graph)
|
||||
installMetricSamplerGraphForTest(t, graph)
|
||||
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
|
||||
times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
|
||||
first, second := backup.SampleMetricSeries("container", "neutral-155", "memory", times), database.SampleMetricSeries("container", "neutral-155", "memory", times)
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
got := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", times)
|
||||
if !reflect.DeepEqual(got, first) && !reflect.DeepEqual(got, second) {
|
||||
t.Error("one series mixed two graph revisions")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
name := "backup-orchestrator"
|
||||
if i%2 == 0 {
|
||||
name = "database-primary"
|
||||
}
|
||||
dataMu.Lock()
|
||||
mockGraph.State.Containers[0].Name = name
|
||||
fixtureDataVersion.Add(1)
|
||||
dataMu.Unlock()
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func BenchmarkCanonicalMetricSeriesSamplerReuse(b *testing.B) {
|
||||
graph := FixtureGraph{State: models.StateSnapshot{VMs: make([]models.VM, 1000)}}
|
||||
for i := range graph.State.VMs {
|
||||
graph.State.VMs[i] = models.VM{ID: fmt.Sprintf("neutral-%d", i), Name: "database-primary"}
|
||||
}
|
||||
installMetricSamplerGraphForTest(b, graph)
|
||||
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
|
||||
times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
|
||||
_ = CurrentMetricSampler()
|
||||
b.Run("legacy-cloned-graph-per-series", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("vm", "neutral-1", "memory", times)
|
||||
}
|
||||
})
|
||||
b.Run("current-immutable-sampler", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CurrentMetricSampler().SampleMetricSeries("vm", "neutral-1", "memory", times)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ func normalizeMockMetricTimestamp(at time.Time, interval time.Duration) time.Tim
|
||||
|
||||
func canonicalMetricSeries(resourceType, resourceID, metric string, timestamps []time.Time) []float64 {
|
||||
return canonicalMetricSeriesWithSampler(
|
||||
mock.NewMetricSampler(mock.CurrentFixtureGraph()),
|
||||
mock.CurrentMetricSampler(),
|
||||
resourceType,
|
||||
resourceID,
|
||||
metric,
|
||||
|
||||
@@ -549,7 +549,7 @@ func TestReleaseControlPlaneFilesExist(t *testing.T) {
|
||||
"Direction changes must be normalized",
|
||||
"Do not wait for a special governance prompt",
|
||||
"stable or GA promotion readiness",
|
||||
"v6-product-lane-expansion",
|
||||
"v6-release-reliability",
|
||||
"v6-ga-promotion",
|
||||
"v6-rc-stabilization",
|
||||
"available_candidate_lane_queue",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package repoctl
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetupBootstrapDocsStayOnCanonicalArtifactContract(t *testing.T) {
|
||||
apiRel := "docs/API.md"
|
||||
@@ -17,10 +21,24 @@ func TestSetupBootstrapDocsStayOnCanonicalArtifactContract(t *testing.T) {
|
||||
pbsRel := "docs/PBS.md"
|
||||
pbsDoc := readRepoFile(t, pbsRel)
|
||||
assertContainsAll(t, pbsRel, pbsDoc, []string{
|
||||
`curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`,
|
||||
"Pulse generates that full command for you from **Settings → Infrastructure**",
|
||||
"### Method 1: API-Only Connection (Recommended)",
|
||||
"proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs",
|
||||
"proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'",
|
||||
`chmod 600 "$HOME/.config/pulse/pbs-agent-token"`,
|
||||
`--output "$HOME/.config/pulse/pbs-agent-install.sh"`,
|
||||
"https://pulse.example.com/install.sh",
|
||||
`--token-file "$HOME/.config/pulse/pbs-agent-token"`,
|
||||
"Stop if the download fails",
|
||||
"Do not bypass certificate checks",
|
||||
})
|
||||
assertContainsNone(t, pbsRel, pbsDoc, []string{
|
||||
`curl -sSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | bash`,
|
||||
// Inspect executable fences, not prose that warns against unsafe flags.
|
||||
commands := strings.Join(regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllString(pbsDoc, -1), "\n")
|
||||
assertContainsNone(t, pbsRel, commands, []string{
|
||||
"PULSE_SETUP_TOKEN=",
|
||||
"sudo env PULSE_SETUP_TOKEN=",
|
||||
"curl -k",
|
||||
"--insecure",
|
||||
`--token "`,
|
||||
"| bash",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +176,47 @@ links, must be 260 characters or fewer. Use no semicolon or em dash characters.]
|
||||
section when there is none.]
|
||||
EOF
|
||||
|
||||
# v6.4.6 and later use the current reader-facing format, not the old forced
|
||||
# outcome/dash list. The renderer still validates the exact version and safety.
|
||||
if python3 - "$VERSION" <<'PY'
|
||||
import re, sys
|
||||
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?", sys.argv[1])
|
||||
sys.exit(0 if match and tuple(map(int, match.groups())) >= (6, 4, 6) else 1)
|
||||
PY
|
||||
then
|
||||
read -r -d '' NOTE_FORMAT <<EOF || true
|
||||
# Pulse v${VERSION} Release Notes
|
||||
|
||||
[One short paragraph explaining the customer outcome of this release.]
|
||||
|
||||
## Highlights
|
||||
|
||||
- [One to three plain-text outcomes, each 140 characters or fewer. No markup,
|
||||
links or issue references in this short list.]
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- [Plain-language change, where users notice it and why it matters.]
|
||||
|
||||
[Choose only relevant change groups: Alerts and notifications, Disks and
|
||||
storage, Proxmox, PBS and backups, TrueNAS, vSphere and Docker, Install, updates
|
||||
and agents, Updates and agents, Pulse Pro, AI and hosted, Monitoring and service
|
||||
health, Security, Other improvements. A narrow patch may use What's improved
|
||||
instead. Do not create empty groups or repeat the same change between groups.
|
||||
Each full bullet must be 260 characters or fewer. No semicolons or em dashes.]
|
||||
|
||||
## Known issues
|
||||
|
||||
[Only unresolved user symptoms with an actionable workaround or next step.
|
||||
Omit this section when there are none.]
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
[Only compatibility, signing, companion-app or required operator information.
|
||||
Omit this section when there is none. No internal verification status.]
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Strip accidental markdown fences and anything before the release title.
|
||||
clean_notes() {
|
||||
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \
|
||||
|
||||
@@ -1147,15 +1147,15 @@ func TestCurrentStablePatchReleasePacketTracksInstallMetadata(t *testing.T) {
|
||||
releaseNotesPath := repoFile("docs", "releases", "RELEASE_NOTES_v"+version+".md")
|
||||
changelogPath := repoFile("docs", "releases", "V6_CHANGELOG_v"+version+".md")
|
||||
|
||||
assertFileContainsAllNormalized(t, releaseNotesPath,
|
||||
"`v"+version+"` is a stable patch release",
|
||||
"`v"+previous+"`",
|
||||
"## What's improved",
|
||||
"not Authenticode-signed",
|
||||
"Unknown Publisher warning",
|
||||
"does not require a companion mobile release",
|
||||
"rollback target is stable `v"+previous+"`",
|
||||
)
|
||||
// The changelog below binds stable maturity and promotion lineage. Public
|
||||
// notes bind the version and operator safety, not one author's boilerplate.
|
||||
notes, err := os.ReadFile(releaseNotesPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, issue := range stablePatchReleaseNotesIssues(string(notes), version, previous) {
|
||||
t.Errorf("%s: %s", releaseNotesPath, issue)
|
||||
}
|
||||
changelogRequired := make([]string, 0, 7)
|
||||
changelogRequired = append(changelogRequired,
|
||||
"Version: `v"+version+"`",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/configapi"
|
||||
)
|
||||
|
||||
// Exercise the real install.sh metadata parser with the actual current server
|
||||
// envelope and coherent older-server envelopes. This does not install a server
|
||||
// or execute an artifact's command string. Credentials enter Python via FD3.
|
||||
func TestRootInstallerPrivateBootstrapMetadata(t *testing.T) {
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := `3<<<"$setup_response" <<'PY'` + "\n"
|
||||
_, body, ok := strings.Cut(string(source), marker)
|
||||
if !ok {
|
||||
t.Fatal("setup parser must receive response on private FD3")
|
||||
}
|
||||
parser, _, ok := strings.Cut(body, "\nPY\n")
|
||||
if !ok {
|
||||
t.Fatal("setup parser end missing")
|
||||
}
|
||||
const pulseURL = "http://10.1.2.3:7655"
|
||||
const host = "https://pve.example:8006"
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
for _, backup := range []bool{false, true} {
|
||||
for _, transport := range []string{"modern", "legacy", "mixed"} {
|
||||
t.Run(transport+"_backup_"+strconv.FormatBool(backup), func(t *testing.T) {
|
||||
artifact := configapi.BuildSetupScriptInstallArtifact(pulseURL, "pve", host, pulseURL, backup, token, time.Now().Add(time.Hour).Unix())
|
||||
if transport != "modern" {
|
||||
download, err := url.Parse(artifact.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := download.Query()
|
||||
query.Set("setup_token", token)
|
||||
download.RawQuery = query.Encode()
|
||||
artifact.DownloadURL = download.String()
|
||||
if transport == "legacy" {
|
||||
command := `curl '` + artifact.URL + `' | { if [ "$(id -u)" -eq 0 ]; then env PULSE_SETUP_TOKEN='` + token + `' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='` + token + `' bash; fi; }`
|
||||
artifact.Command = command
|
||||
artifact.CommandWithEnv = command
|
||||
artifact.CommandWithoutEnv = strings.ReplaceAll(command, "env PULSE_SETUP_TOKEN='"+token+"' ", "")
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(artifact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(t.TempDir(), "response")
|
||||
if err := os.WriteFile(input, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.Open(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
cmd := exec.Command("python3", "-", pulseURL, host, strconv.FormatBool(backup))
|
||||
cmd.Stdin = strings.NewReader(parser)
|
||||
cmd.ExtraFiles = []*os.File{file}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("parser failed: %v", err)
|
||||
}
|
||||
fields := strings.Split(strings.TrimSuffix(string(out), "\n"), "\t")
|
||||
if len(fields) != 12 {
|
||||
t.Fatal("unexpected metadata fields")
|
||||
}
|
||||
if transport == "mixed" {
|
||||
if fields[6] != "" || fields[7] != "" || fields[8] != "" {
|
||||
t.Fatal("mixed transport accepted")
|
||||
}
|
||||
return
|
||||
}
|
||||
if fields[0] != token || fields[1] != "pve" || fields[2] != host || fields[3] != artifact.URL || fields[4] != artifact.DownloadURL || fields[6] != artifact.Command || fields[7] != artifact.CommandWithEnv || fields[8] != artifact.CommandWithoutEnv || fields[11] != "live" {
|
||||
t.Fatal("root installer discarded valid bootstrap metadata or token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These are reader-facing change sections, not internal qualification headings.
|
||||
var releaseNoteChangeHeadings = map[string]bool{
|
||||
"what's improved": true, "what’s improved": true,
|
||||
"alerts and notifications": true, "disks and storage": true,
|
||||
"proxmox, pbs and backups": true, "truenas, vsphere and docker": true,
|
||||
"install, updates and agents": true, "updates and agents": true,
|
||||
"pulse pro, ai and hosted": true, "monitoring and service health": true,
|
||||
"security": true, "other improvements": true,
|
||||
}
|
||||
|
||||
func stablePatchReleaseNotesIssues(notes, version, previous string) []string {
|
||||
var issues []string
|
||||
lines := strings.Split(strings.TrimSpace(notes), "\n")
|
||||
title := regexp.MustCompile(`^# Pulse v` + regexp.QuoteMeta(version) + `(?: Release Notes)?$`)
|
||||
if !title.MatchString(strings.TrimSpace(lines[0])) {
|
||||
issues = append(issues, "release title must name the exact current version")
|
||||
}
|
||||
changesSection, hasChanges := false, false
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
changesSection = releaseNoteChangeHeadings[strings.ToLower(strings.TrimPrefix(line, "## "))]
|
||||
} else if strings.HasPrefix(line, "#") {
|
||||
changesSection = false
|
||||
} else if changesSection && strings.HasPrefix(line, "- ") && len(strings.TrimSpace(line[2:])) > 0 {
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
if !hasChanges {
|
||||
issues = append(issues, "notes need non-empty user-facing change bullets")
|
||||
}
|
||||
normalized := strings.ToLower(strings.Join(strings.Fields(notes), " "))
|
||||
for _, required := range []string{
|
||||
"not authenticode-signed", "unknown publisher warning",
|
||||
"rollback target is stable `v" + previous + "`",
|
||||
} {
|
||||
if !strings.Contains(normalized, required) {
|
||||
issues = append(issues, "missing operator safety disclosure: "+required)
|
||||
}
|
||||
}
|
||||
compatibleMobile := strings.Contains(normalized, "does not require a companion mobile release") ||
|
||||
strings.Contains(normalized, "pulse mobile works with this release unchanged") ||
|
||||
strings.Contains(normalized, "pulse mobile is being retired on 31 march 2027. it keeps working until then.")
|
||||
if !compatibleMobile {
|
||||
issues = append(issues, "missing companion mobile compatibility or approved retirement disclosure")
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func TestStablePatchReleaseNotesAcceptAuthoredPublishedCopy(t *testing.T) {
|
||||
// Exact public source b50aeb6a8d9e38ab362b23f4ed15a010f675a90f.
|
||||
// A fixture is not permission to edit or re-render the published release.
|
||||
notes, err := os.ReadFile("testdata/release-notes-v6.4.5-authored.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issues := stablePatchReleaseNotesIssues(string(notes), "6.4.5", "6.4.1"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStablePatchReleaseNotesKeepIdentityAndSafety(t *testing.T) {
|
||||
notes := "# Pulse v6.4.6 Release Notes\n\nPulse updates and History are clearer.\n\n" +
|
||||
"## What's improved\n\n- History stays with the current host.\n\n" +
|
||||
"## Before you upgrade\n\nWindows binaries are not Authenticode-signed. " +
|
||||
"Windows may show an Unknown Publisher warning. " +
|
||||
"This does not require a companion mobile release. " +
|
||||
"The rollback target is stable `v6.4.5`.\n"
|
||||
if issues := stablePatchReleaseNotesIssues(notes, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
for _, change := range []struct{ name, from, to string }{
|
||||
{"wrong title", "# Pulse v6.4.6", "# Pulse v6.4.5"},
|
||||
{"no changes", "- History stays with the current host.", ""},
|
||||
{"internal changes only", "## What's improved", "## Qualification"},
|
||||
{"signing omitted", "not Authenticode-signed", "signed"},
|
||||
{"publisher warning omitted", "Unknown Publisher warning", "warning"},
|
||||
{"mobile omitted", "does not require a companion mobile release", "requires a new mobile build"},
|
||||
{"wrong rollback", "rollback target is stable `v6.4.5`", "rollback target is stable `v6.4.1`"},
|
||||
} {
|
||||
t.Run(change.name, func(t *testing.T) {
|
||||
if issues := stablePatchReleaseNotesIssues(strings.ReplaceAll(notes, change.from, change.to), "6.4.6", "6.4.5"); len(issues) == 0 {
|
||||
t.Fatal("unsafe or mismatched notes accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Preserve the Markdown title/heading while exercising wrapped safety text.
|
||||
wrapped := strings.ReplaceAll(notes, "Windows binaries are", "Windows\nbinaries are")
|
||||
if issues := stablePatchReleaseNotesIssues(wrapped, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
for _, disclosure := range []string{
|
||||
"Pulse Mobile works with this release unchanged",
|
||||
"Pulse Mobile is being retired on 31 March 2027. It keeps working until then.",
|
||||
} {
|
||||
candidate := strings.ReplaceAll(notes, "This does not require a companion mobile release", disclosure)
|
||||
if issues := stablePatchReleaseNotesIssues(candidate, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,11 +583,12 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
|
||||
`str(expires_raw)`,
|
||||
`expiry_state = "live"`,
|
||||
`expires_int > int(time.time())`,
|
||||
`expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`,
|
||||
`expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"`,
|
||||
`expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`,
|
||||
`expected_download_url = expected_setup_url`,
|
||||
`setup_download_url not in (expected_download_url, legacy_download_url)`,
|
||||
`expected_script_name = "pulse-setup-pve.sh"`,
|
||||
`setup_url != expected_setup_url`,
|
||||
`setup_download_url != expected_download_url`,
|
||||
`modern = setup_download_url == expected_download_url`,
|
||||
`setup_script_name != expected_script_name`,
|
||||
`not setup_command`,
|
||||
`not setup_command_with_env`,
|
||||
@@ -596,8 +597,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
|
||||
`if not _value or expected_setup_url not in _value:`,
|
||||
`'if [ "$(id -u)" -eq 0 ]; then' not in _value`,
|
||||
`'elif command -v sudo >/dev/null 2>&1; then' not in _value`,
|
||||
`if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:`,
|
||||
`elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:`,
|
||||
`valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value`,
|
||||
`valid = "PULSE_SETUP_TOKEN_FILE=" not in _value and (("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value))`,
|
||||
`not token_hint or token_hint == setup_token`,
|
||||
`[[ "$setup_type" != "pve" ]]`,
|
||||
`[[ "$setup_host" != "$normalized_host_url" ]]`,
|
||||
@@ -609,7 +610,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
|
||||
`[[ -z "$setup_command_without_env" ]]`,
|
||||
`[[ -z "$setup_token_hint" ]]`,
|
||||
`[[ "$setup_expiry_state" != "live" ]]`,
|
||||
`host, token_id, token_value, server_name, setup_token = sys.argv[1:]`,
|
||||
`host, token_id, server_name = sys.argv[1:]`,
|
||||
`token_value, setup_token = __import__("os").fdopen(3).read().splitlines()`,
|
||||
`"tokenId": token_id`,
|
||||
`"tokenValue": token_value`,
|
||||
`"authToken": setup_token`,
|
||||
@@ -625,7 +627,7 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
|
||||
`[[ "$register_status" != "success" ]] || [[ "$register_action" != "use_token" ]] || [[ "$register_type" != "pve" ]] || [[ "$register_source" != "script" ]]`,
|
||||
`AUTO_NODE_REGISTERED_NAME="$register_node_name"`,
|
||||
`curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"`,
|
||||
`curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`,
|
||||
`printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`,
|
||||
`token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1)`,
|
||||
`pveum aclmod / -token "$token_id" -role PVEAuditor`,
|
||||
`pveum aclmod / -token "$token_id" -role PulseMonitor`,
|
||||
@@ -1200,7 +1202,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test
|
||||
|
||||
for _, needle := range []string{
|
||||
`smoke_test_pve_auto_register_token() {`,
|
||||
`curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes"`,
|
||||
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes"`,
|
||||
`AUTO_NODE_REGISTER_ERROR="token smoke check failed"`,
|
||||
`smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`,
|
||||
} {
|
||||
@@ -1210,7 +1212,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test
|
||||
}
|
||||
|
||||
smokeCallIdx := strings.Index(script, `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`)
|
||||
registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`)
|
||||
registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`)
|
||||
if smokeCallIdx < 0 || registerIdx < 0 || smokeCallIdx > registerIdx {
|
||||
t.Fatalf("expected token smoke check to run before /api/auto-register (smoke=%d register=%d)", smokeCallIdx, registerIdx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Pulse v6.4.5
|
||||
|
||||
The first stable release since v6.4.1. It brings everyone the fixes that have been in the v6.4.5 previews over the past month: quieter and more accurate alerts, fewer disk writes, working updates and Windows agent installs, and better PBS, TrueNAS, vSphere and Docker support.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Far fewer false disk-wear alerts.** Endurance counters no longer raise false disk-wear warnings, disk I/O totals are no longer inflated, and a recovered or excluded disk stops re-alerting (#2112).
|
||||
- **Stable updates work again.** The updater reads the full release information and offers the right build, including on ARMv6 and ARMv7 (#2282).
|
||||
- **Windows agent installs and auto-updates work again.** Downloads no longer fail with HTTP 404 (#1820, #2125).
|
||||
- **Machines with the same name stay separate.** Hosts, Docker, Kubernetes and Proxmox systems that share a short name or token no longer get their alerts, metrics or actions mixed up (#1753, #1930).
|
||||
- **Less writing to SSD and flash storage.** Pulse no longer rewrites unchanged alert state on every cycle (#1966).
|
||||
- **Large alert histories no longer freeze Pulse.** They load gradually instead of blocking the server, and a slow start now shows its status with a retry button (#2129, #2146).
|
||||
|
||||
## Alerts and notifications
|
||||
|
||||
- A problem that continues after its parent recovers now gets its own notification, and grouped alerts no longer repeat (#2336).
|
||||
- Many alerts recovering at once are grouped into one notification instead of flooding your notification queue (#2160).
|
||||
- Notifications due at startup wait until your webhook, email and Apprise settings have loaded, so they are no longer dropped (#2160).
|
||||
- A warning that becomes critical is now delivered, escalations respect current acknowledgements and snoozes, and an automatic acknowledgement survives the alert firing again (#1801, #2173).
|
||||
- Removed or suppressed resources stop creating alerts (#2237).
|
||||
- The Alerts overview counts now match the incidents shown, alert-card footers line up, and the Alert Thresholds list keeps every host visible while scrolling (#2119, #2130).
|
||||
|
||||
## Disks and storage
|
||||
|
||||
- The same USB disk seen by both Proxmox and the agent can now be shown once when one source lacks a stable ID, while ambiguous or conflicting disks stay separate (#2076).
|
||||
- VMs and containers show the physical disks, SMART and RAID details reported by their linked agent (#2263).
|
||||
- Long mount paths in the Filesystems panel are shown in full instead of being cut to identical prefixes (#2121).
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- PBS host and config backups no longer trigger repeated guest backup-age alerts, and a guest backed up locally and copied to PBS is counted once (#1741, #1721, #2136).
|
||||
- PBS History keeps each host and datastore separate across refreshes (#1723).
|
||||
- PBS datastore health uses your warning and critical thresholds (#1448).
|
||||
- Proxmox LXC memory uses the reading from an online Pulse agent inside the container, which excludes the page cache, when the agent's total matches the container's limit (#2148).
|
||||
- The resource drawer stays on the tab you picked during live refreshes (#1723).
|
||||
- Your "Verify SSL Certificate" choice for a node or PBS is kept through agent re-registration and cluster changes (#2140).
|
||||
|
||||
## TrueNAS, vSphere and Docker
|
||||
|
||||
- TrueNAS: AMD temperatures are no longer inflated, TrueNAS CORE memory is read correctly, CPU and memory charts keep their data, and idle sessions poll less often (#2122, #2077, #1893).
|
||||
- vSphere: enrichment works again with vSphere 8.0.3 and shows the actual API error instead of HTTP 500 (#2070).
|
||||
- Docker: containers such as PostgreSQL no longer show a false "update available" (#2110).
|
||||
|
||||
## Install, updates and agents
|
||||
|
||||
- A failed update no longer leaves stale backups behind, and a successful one is no longer reported as failed (#2127, #2128).
|
||||
- FreeBSD and pfSense agent installs verify checksums without GNU tools, and the copied install command keeps its line breaks (#2123).
|
||||
- Agents that re-enroll with a changed identity heal cleanly without a burst of events, and a removed agent stays removed (#2113, #1586).
|
||||
- Upgrades from v5 keep your existing hosts (#1913).
|
||||
- The upgrade guide warns that on some Proxmox community-script containers `/bin/update` is the community-scripts updater, and shows the signed Pulse installer instead (#2129).
|
||||
- Previews stuck on the old broken self-updater may need one manual, version-pinned update (#2282).
|
||||
|
||||
## Pulse Pro, AI and hosted
|
||||
|
||||
- AI Patrol caches its system prompt, so repeated runs cost less (#2118), and a saved Patrol objective whose watcher was lost or rejected is restored instead of going unchecked (#2147).
|
||||
- AI knowledge saves no longer conflict when made quickly one after another.
|
||||
- Ollama Basic Auth, the configured-admin setting and security setup choices survive saving and restarting.
|
||||
- Provider (MSP) installs keep each client's networks separate and client workspaces healthy through upgrades, and agent install tokens are created in the right client workspace (#2247, #2226, #2209).
|
||||
- Organization owners can reach the settings their organization allows (#2208).
|
||||
|
||||
## Other improvements
|
||||
|
||||
- Availability views with 20 or more checks open in the fleet view, and your chosen table or fleet view stays put across refreshes and shared links.
|
||||
- The metrics store drops replayed samples and is more robust across shutdowns, rollups and upgrades.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **PBS History for a replaced PBS host can reappear.** If a PBS host's identity changes, its old History may show again. No monitoring data is lost. The fix is planned for v6.4.6 (#2343).
|
||||
- **The update screen can stay on "Downloading update… 10%".** The update still completes in the background. Refresh the page after a minute to see the new version. A fix is planned for v6.4.6.
|
||||
- Some PBS History layouts that combine the API and an agent are still not fully resolved (#1723).
|
||||
- Disk writes are much lower but not yet at their final level on busy installs. Keep an eye on write activity if you run on flash storage, and some older duplicate incidents may remain (#1966).
|
||||
- A small number of v5 to v6 upgrade problems remain for specific setups (#1913).
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
- Back up your Pulse data directory and configuration, and keep the backup until you have checked everything works.
|
||||
- This release includes the changes from v6.4.2, which was never published. If you use SSO only, map at least one trusted identity-provider group to the built-in `admin` role before upgrading so you keep admin access.
|
||||
- Windows Unified Agent binaries are not Authenticode-signed while SignPath remains unavailable, so Windows may show an Unknown Publisher warning. Verify downloads with the published checksums and detached signatures.
|
||||
- Pulse Mobile works with this release unchanged.
|
||||
- The rollback target is stable `v6.4.1`. On systemd and Proxmox LXC installs, use `sudo /bin/update --version v6.4.1`. For Docker Compose, pin `rcourtman/pulse:6.4.1` and recreate the container.
|
||||
@@ -176,6 +176,7 @@ class ReleaseNoteVisualPlanTest(unittest.TestCase):
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"version": "6.4.0",
|
||||
"rollback_target": "v6.3.2",
|
||||
"rollback_command": "sudo /bin/update --version v6.3.2",
|
||||
},
|
||||
|
||||
@@ -28,6 +28,13 @@ _CUSTOMER_SECTION_HEADINGS = {
|
||||
"before you upgrade",
|
||||
"known issues",
|
||||
}
|
||||
_CUSTOMER_CHANGE_GROUPS = {
|
||||
"alerts and notifications", "disks and storage",
|
||||
"proxmox, pbs and backups", "truenas, vsphere and docker",
|
||||
"install, updates and agents", "updates and agents",
|
||||
"pulse pro, ai and hosted", "monitoring and service health",
|
||||
"security", "other improvements",
|
||||
}
|
||||
_INTERNAL_RELEASE_LANGUAGE_RE = re.compile(
|
||||
r"\b(?:"
|
||||
r"readiness assertions?"
|
||||
@@ -251,16 +258,23 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
||||
f"the release summary must be {_MAX_CUSTOMER_SUMMARY_LENGTH} characters or fewer"
|
||||
)
|
||||
|
||||
# New packets follow the grouped, plain-language release story. Retain the
|
||||
# historical validator for older packets without rewriting published prose.
|
||||
grouped = (_release_core(version) or (0, 0, 0)) >= (6, 4, 6)
|
||||
allowed_headings = _CUSTOMER_SECTION_HEADINGS | (
|
||||
_CUSTOMER_CHANGE_GROUPS | {"highlights"} if grouped else set()
|
||||
)
|
||||
headings: dict[str, int] = {}
|
||||
for index, line in enumerate(lines):
|
||||
heading = re.fullmatch(r"##[ \t]+(.+?)\s*", line)
|
||||
if not heading:
|
||||
continue
|
||||
normalized = re.sub(r"\s+", " ", heading.group(1)).lower()
|
||||
if normalized not in _CUSTOMER_SECTION_HEADINGS:
|
||||
if normalized not in allowed_headings:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes may only use What's improved, "
|
||||
"Fixes, Before you upgrade, and Known issues sections"
|
||||
"supported customer change groups, Highlights, Before you upgrade, "
|
||||
"and Known issues sections"
|
||||
)
|
||||
if normalized in headings:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
@@ -272,28 +286,35 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
||||
(key for key in ("what's improved", "what’s improved") if key in headings),
|
||||
None,
|
||||
)
|
||||
if improvements_key is None:
|
||||
change_keys = [key for key in headings if key in _CUSTOMER_CHANGE_GROUPS] if grouped else []
|
||||
if improvements_key:
|
||||
change_keys.append(improvements_key)
|
||||
if not change_keys:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes must contain a What's improved section"
|
||||
"customer-facing release notes must contain a What's improved section "
|
||||
"or a supported customer change group"
|
||||
)
|
||||
|
||||
improvements = _flat_bullet_items(
|
||||
_section_lines(text, headings[improvements_key]),
|
||||
"What's improved",
|
||||
)
|
||||
if not improvements:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved must contain at least one bullet"
|
||||
)
|
||||
for item in improvements:
|
||||
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
|
||||
seen_changes: set[str] = set()
|
||||
for key in change_keys:
|
||||
improvements = _flat_bullet_items(_section_lines(text, headings[key]), key)
|
||||
if not improvements:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
|
||||
)
|
||||
if not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved bullets must start with a short bold outcome followed by a dash"
|
||||
f"{key} must contain at least one bullet"
|
||||
)
|
||||
for item in improvements:
|
||||
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
|
||||
)
|
||||
if not grouped and not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved bullets must start with a short bold outcome followed by a dash"
|
||||
)
|
||||
normalized_item = re.sub(r"\s+", " ", item).casefold()
|
||||
if grouped and normalized_item in seen_changes:
|
||||
raise ReleaseBodyIntegrityError("customer change groups must not repeat a change")
|
||||
seen_changes.add(normalized_item)
|
||||
|
||||
if "fixes" in headings:
|
||||
if _requires_single_change_list(version):
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
import tempfile
|
||||
@@ -31,6 +33,123 @@ def _discover_rc_draft_packet_paths() -> tuple[str, ...]:
|
||||
|
||||
|
||||
class RenderReleaseBodyTest(unittest.TestCase):
|
||||
def grouped_notes(self, version: str = "6.4.6-rc.1") -> str:
|
||||
return f"""# Pulse v{version} Release Notes
|
||||
|
||||
PBS History stays with the current host and update progress is clearer.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Replaced PBS hosts no longer return in History.
|
||||
- Update progress follows the work until it finishes.
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- History no longer brings back a replaced PBS host (#2343).
|
||||
|
||||
## Updates and agents
|
||||
|
||||
- Update progress no longer stays on Downloading 10% after the update completes.
|
||||
|
||||
## Known issues
|
||||
|
||||
- Some combined API and agent PBS layouts still lack History. Use the API view.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
Back up your data directory and keep the backup until you have checked the update.
|
||||
"""
|
||||
|
||||
def test_next_patch_accepts_grouped_plain_language_notes(self) -> None:
|
||||
for version in ("6.4.6-rc.1", "6.4.6", "6.5.0-rc.1", "6.5.0"):
|
||||
with self.subTest(version=version):
|
||||
notes = self.grouped_notes(version)
|
||||
render_release_body.validate_release_notes_shape(notes, version)
|
||||
args = type("Args", (), {"version": version,
|
||||
"rollback_target": "v6.4.5",
|
||||
"rollback_command": "sudo /bin/update --version v6.4.5"})()
|
||||
body = "\n\n".join((notes.strip(),
|
||||
render_release_body.build_installation_section(version),
|
||||
render_release_body.build_rollback_section(args))) + "\n"
|
||||
render_release_body.validate_release_body_shape(body, version, expected_body=body)
|
||||
self.assertIn("## Proxmox, PBS and backups", body)
|
||||
self.assertEqual(body.count("## Roll back\n"), 1)
|
||||
self.assertIn("only when `/bin/update` was installed by the Pulse server installer", body)
|
||||
|
||||
def test_grouped_notes_keep_structure_and_customer_safety(self) -> None:
|
||||
valid = self.grouped_notes()
|
||||
cases = {
|
||||
"wrong version": valid.replace("v6.4.6-rc.1", "v6.4.5"),
|
||||
"empty group": valid.replace("- History no longer brings back a replaced PBS host (#2343).", ""),
|
||||
"internal section": valid.replace("## Updates and agents", "## Release Qualification"),
|
||||
"internal prose": valid.replace("after the update completes.", "after exact-SHA release gates passed."),
|
||||
"long change": valid.replace("History no longer brings back a replaced PBS host (#2343).", "x" * 261),
|
||||
"duplicate section": valid + "\n## Updates and agents\n\n- Another change.\n",
|
||||
"duplicate change": valid.replace("Update progress no longer stays on Downloading 10% after the update completes.",
|
||||
"History no longer brings back a replaced PBS host (#2343)."),
|
||||
"flattened": valid.replace("\n\n## Updates and agents\n\n", " ## Updates and agents "),
|
||||
"highlights only": "# Pulse v6.4.6-rc.1 Release Notes\n\nHistory is clearer.\n\n## Highlights\n\n- History stays with its host.\n",
|
||||
}
|
||||
for name, notes in cases.items():
|
||||
with self.subTest(name=name):
|
||||
with self.assertRaises(render_release_body.ReleaseBodyIntegrityError):
|
||||
render_release_body.validate_release_notes_shape(notes, "6.4.6-rc.1")
|
||||
|
||||
def test_metadata_and_renderer_change_group_names_stay_aligned(self) -> None:
|
||||
go_source = (_REPO_ROOT / "scripts/installtests/release_notes_contract_test.go").read_text()
|
||||
group_map = go_source.split("var releaseNoteChangeHeadings = map[string]bool{", 1)[1].split("}", 1)[0]
|
||||
groups = set(re.findall(r'"([^"]+)":\s*true', group_map))
|
||||
self.assertEqual(groups, render_release_body._CUSTOMER_CHANGE_GROUPS | {"what's improved", "what’s improved"})
|
||||
|
||||
def test_generator_prompts_and_renderer_agree_on_grouped_notes(self) -> None:
|
||||
# Invoke the actual shell authoring path with a deterministic, local
|
||||
# model double. No model service, credentials or network are used.
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
scripts = root / "scripts/release_control"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "render_release_body.py").write_text(
|
||||
(_REPO_ROOT / "scripts/release_control/render_release_body.py").read_text())
|
||||
def git(*args: str) -> None:
|
||||
subprocess.run(["git", "-c", "user.name=Release Note Test",
|
||||
"-c", "user.email=notes@example.invalid", *args], cwd=root,
|
||||
check=True, capture_output=True)
|
||||
git("init", "-b", "main")
|
||||
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "previous stable")
|
||||
git("tag", "v6.4.5")
|
||||
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "candidate changes")
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
model = bin_dir / "codex"
|
||||
model.write_text("""#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
args = sys.argv[1:]
|
||||
assert 'OPENAI_API_KEY' not in os.environ
|
||||
with open(os.environ['NOTE_TEST_PROMPTS'], 'a') as trace:
|
||||
trace.write(json.dumps(args[-1]) + '\\n')
|
||||
pathlib.Path(args[args.index('-o') + 1]).write_text(pathlib.Path(os.environ['NOTE_TEST_NOTES']).read_text())
|
||||
""")
|
||||
model.chmod(0o755)
|
||||
notes = root / "notes.md"
|
||||
notes.write_text(self.grouped_notes())
|
||||
prompts = root / "prompts.jsonl"
|
||||
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
|
||||
"NOTE_TEST_NOTES": str(notes), "NOTE_TEST_PROMPTS": str(prompts),
|
||||
"OPENAI_API_KEY": "synthetic-value-must-be-scrubbed"}
|
||||
for key in ("SAVE_TO_FILE", "RELEASE_NOTES_TRACE_DIR", "RELEASE_NOTE_VISUAL_PLAN_FILE"):
|
||||
env.pop(key, None)
|
||||
result = subprocess.run(["bash", str(_REPO_ROOT / "scripts/generate-release-notes.sh"),
|
||||
"6.4.6-rc.1", "v6.4.5"], cwd=root, env=env,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, notes.read_text())
|
||||
recorded = [json.loads(line) for line in prompts.read_text().splitlines()]
|
||||
self.assertEqual(len(recorded), 4) # research, draft, review, omission
|
||||
for prompt in recorded[1:]:
|
||||
self.assertIn("## Highlights", prompt)
|
||||
self.assertIn("## Proxmox, PBS and backups", prompt)
|
||||
self.assertIn("No internal verification status", prompt)
|
||||
|
||||
def test_v642_security_packet_keeps_both_admin_boundaries_visible(self) -> None:
|
||||
notes = (
|
||||
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.2.md"
|
||||
|
||||
@@ -489,6 +489,8 @@ if module.MAX_RECEIPT_BYTES <= 0:
|
||||
def test_manifest_contract_binds_transitive_harness_and_production_boundary(self) -> None:
|
||||
manifest = json.loads((Path(__file__).with_name("secure_runtime_rootful_source_manifest_v1.json")).read_text(encoding="utf-8"))
|
||||
required = {
|
||||
"scripts/installtests/release_notes_contract_test.go",
|
||||
"scripts/installtests/testdata/release-notes-v6.4.5-authored.md",
|
||||
"scripts/installtests/secure_runtime_rootful_qualification_test.go",
|
||||
"scripts/installtests/secure_runtime_rootless_qualification_test.go",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
@@ -506,6 +508,7 @@ if module.MAX_RECEIPT_BYTES <= 0:
|
||||
}
|
||||
self.assertTrue(compiled_test_inputs.issubset(manifest["exact_paths"]))
|
||||
recursive_roots = set(manifest["recursive_roots"])
|
||||
unbound_dependencies = set()
|
||||
for target, include_tests in (
|
||||
("./scripts/installtests", True),
|
||||
("./cmd/pulse-agent", False),
|
||||
@@ -527,12 +530,16 @@ if module.MAX_RECEIPT_BYTES <= 0:
|
||||
if not raw_directory or directory == repo_root / "scripts" / "installtests":
|
||||
continue
|
||||
relative = directory.relative_to(repo_root).as_posix()
|
||||
self.assertTrue(
|
||||
any(relative == root or relative.startswith(root + "/") for root in recursive_roots),
|
||||
f"compiled dependency for {target} is outside the source manifest: {relative}",
|
||||
)
|
||||
if not any(relative == root or relative.startswith(root + "/") for root in recursive_roots):
|
||||
unbound_dependencies.add(f"{target}: {relative}")
|
||||
self.assertFalse(
|
||||
unbound_dependencies,
|
||||
"compiled dependencies outside the source manifest: " + ", ".join(sorted(unbound_dependencies)),
|
||||
)
|
||||
self.assertIn("internal/agenthelper", manifest["recursive_roots"])
|
||||
self.assertIn("pkg/auth", manifest["recursive_roots"])
|
||||
self.assertIn("internal/filesystemprobe", manifest["recursive_roots"])
|
||||
self.assertIn("pkg/agents/filesystem", manifest["recursive_roots"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -28,12 +28,14 @@
|
||||
"scripts/installtests/provider_msp_deploy_test.go",
|
||||
"scripts/installtests/pulse_auto_update_test.go",
|
||||
"scripts/installtests/release_ldflags_test.go",
|
||||
"scripts/installtests/release_notes_contract_test.go",
|
||||
"scripts/installtests/root_install_sh_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go",
|
||||
"scripts/installtests/secure_runtime_platform_matrix_test.go",
|
||||
"scripts/installtests/secure_runtime_rootful_qualification_test.go",
|
||||
"scripts/installtests/secure_runtime_rootless_qualification_test.go",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
"scripts/installtests/testdata/release-notes-v6.4.5-authored.md",
|
||||
"scripts/installtests/testdata/secure_runtime_docker_fixture.go",
|
||||
"scripts/installtests/umask_unix_test.go",
|
||||
"scripts/installtests/uninstall_sensor_proxy_test.go",
|
||||
@@ -61,6 +63,7 @@
|
||||
"internal/config",
|
||||
"internal/crypto",
|
||||
"internal/dockeragent",
|
||||
"internal/filesystemprobe",
|
||||
"internal/hostagent",
|
||||
"internal/hostmetrics",
|
||||
"internal/kubernetesagent",
|
||||
@@ -90,6 +93,7 @@
|
||||
"internal/vmware",
|
||||
"pkg/auth",
|
||||
"pkg/agents/docker",
|
||||
"pkg/agents/filesystem",
|
||||
"pkg/agents/host",
|
||||
"pkg/agents/kubernetes",
|
||||
"pkg/aicontracts",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the administration guides with synthetic credentials and loopback.
|
||||
|
||||
Run with pulse-worker-source-proof; these fixtures do not perform real role,
|
||||
tenant or audit operations. The Go documentation tests check the actual handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server
|
||||
|
||||
|
||||
DOCS = ROOT / "docs"
|
||||
GUIDES = ("RBAC", "AUDIT_LOGGING", "MULTI_TENANT")
|
||||
EXPECTED = {
|
||||
"RBAC": (
|
||||
("POST", "/api/admin/roles", {
|
||||
"id": "alert-manager", "name": "Alert Manager",
|
||||
"description": "Can view and manage alerts",
|
||||
"permissions": [{"action": "read", "resource": "alerts"},
|
||||
{"action": "write", "resource": "alerts"},
|
||||
{"action": "read", "resource": "nodes"}],
|
||||
}),
|
||||
("GET", "/api/admin/roles", None),
|
||||
("PUT", "/api/admin/roles/alert-manager", {
|
||||
"name": "Alert Manager", "description": "Updated description",
|
||||
"permissions": [{"action": "read", "resource": "alerts"},
|
||||
{"action": "write", "resource": "alerts"},
|
||||
{"action": "read", "resource": "nodes"},
|
||||
{"action": "read", "resource": "ai"}],
|
||||
}),
|
||||
("DELETE", "/api/admin/roles/alert-manager", None),
|
||||
("GET", "/api/admin/users", None),
|
||||
("PUT", "/api/admin/users/jane/roles", {"roleIds": ["alert-manager", "viewer"]}),
|
||||
("PUT", "/api/admin/users/jane/roles", {"roleIds": []}),
|
||||
("DELETE", "/api/admin/users/jane", None),
|
||||
),
|
||||
"AUDIT_LOGGING": (
|
||||
("GET", "/api/audit?limit=50", None),
|
||||
("GET", "/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false", None),
|
||||
("GET", "/api/audit/summary", None),
|
||||
("GET", "/api/audit/export", None),
|
||||
("GET", "/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify", None),
|
||||
),
|
||||
"MULTI_TENANT": (
|
||||
("GET", "/api/orgs/production-datacenter/members", None),
|
||||
("GET", "/api/orgs/production-datacenter/shares/incoming", None),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def recipes(name: str) -> list[str]:
|
||||
requests = []
|
||||
for block in re.findall(r"```bash\n(.*?)```", (DOCS / f"{name}.md").read_text(), re.DOTALL):
|
||||
# The audit read examples share a fence but are independent operations.
|
||||
for step in re.split(r"\n(?=# )", block):
|
||||
if "curl " in step:
|
||||
requests.append(step)
|
||||
return requests
|
||||
|
||||
|
||||
class AdminDocsTest(unittest.TestCase):
|
||||
def test_shell_recipes_never_expose_credentials_or_override_tls(self):
|
||||
for name in GUIDES:
|
||||
with self.subTest(guide=name):
|
||||
steps = recipes(name)
|
||||
self.assertEqual(len(steps), len(EXPECTED[name]))
|
||||
for step in steps:
|
||||
self.assertNotRegex(step, r"(?:\b\w*TOKEN=|Authorization:|X-API-Token:|Bearer\s|--cookie\b)")
|
||||
self.assertNotRegex(step, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)")
|
||||
self.assertIn('curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header"', step)
|
||||
if re.search(r"--request (POST|PUT)", step):
|
||||
self.assertIn("--data-binary @-", step)
|
||||
self.assertIn("<<'JSON'", step)
|
||||
|
||||
def test_guides_explain_authority_and_sensitive_output(self):
|
||||
for name in GUIDES:
|
||||
guide = (DOCS / f"{name}.md").read_text()
|
||||
with self.subTest(guide=name):
|
||||
self.assertIn("API.md#-authentication", guide)
|
||||
self.assertIn("HTTPS", guide)
|
||||
self.assertIn("certificate verification", guide)
|
||||
rbac = (DOCS / "RBAC.md").read_text()
|
||||
for boundary in ("full-access (`*`)", "bound to an authorised administrator", "cannot be modified or deleted", "self-escalation", "complete list", "URL-encode"):
|
||||
self.assertIn(boundary, rbac)
|
||||
audit = (DOCS / "AUDIT_LOGGING.md").read_text()
|
||||
for boundary in ("audit:read", "audit_logging", "private directory", "redacted error", "does not reuse filters"):
|
||||
self.assertIn(boundary, audit)
|
||||
org = (DOCS / "MULTI_TENANT.md").read_text()
|
||||
for boundary in ("session-based user authentication", "403 session_required", "CSRF", "settings:read", "organization binding", "pending invitation", "accept the share", "accessRole"):
|
||||
self.assertIn(boundary, org)
|
||||
self.assertNotRegex(org, r"\| `PATCH` \|")
|
||||
|
||||
def test_shipped_copies_are_identical(self):
|
||||
for name in (*GUIDES, "API"):
|
||||
with self.subTest(guide=name):
|
||||
self.assertEqual((DOCS / f"{name}.md").read_bytes(),
|
||||
(ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes())
|
||||
|
||||
def test_all_recipes_send_exact_methods_paths_and_bodies_with_each_header(self):
|
||||
with recording_server() as (port, requests):
|
||||
for name, operations in EXPECTED.items():
|
||||
steps = recipes(name)
|
||||
self.assertEqual(len(steps), len(operations))
|
||||
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
|
||||
for step, (method, path, body) in zip(steps, operations):
|
||||
with self.subTest(guide=name, method=method, path=path, header=key), tempfile.TemporaryDirectory(prefix="admin docs ") as temporary:
|
||||
before = len(requests)
|
||||
result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, step)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(len(requests), before + 1, "a copied step sends exactly one request")
|
||||
sent_path, headers, sent_method, sent_body = requests[-1]
|
||||
self.assertEqual((sent_method, sent_path), (method, path))
|
||||
self.assertEqual(headers[key], value)
|
||||
self.assertNotIn("X-Curlrc-Injected", headers)
|
||||
self.assertNotIn("Authorization" if key == "X-API-Token" else "X-API-Token", headers)
|
||||
if body is None:
|
||||
self.assertEqual(sent_body, b"")
|
||||
else:
|
||||
self.assertEqual(headers["Content-Type"], "application/json")
|
||||
self.assertEqual(json.loads(sent_body), body)
|
||||
|
||||
def test_every_recipe_surfaces_auth_and_license_errors_without_next_steps(self):
|
||||
for status in (401, 402, 403):
|
||||
with recording_server(status) as (port, requests):
|
||||
for name in GUIDES:
|
||||
for step in recipes(name):
|
||||
with self.subTest(status=status, guide=name), tempfile.TemporaryDirectory() as temporary:
|
||||
before = len(requests)
|
||||
result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, step)
|
||||
self.assertEqual(result.returncode, 22, result.stderr.decode())
|
||||
self.assertEqual(len(requests), before + 1)
|
||||
self.assertNotIn(b"Saved private export", result.stdout)
|
||||
# curl preserves an error body, in stdout or the requested file.
|
||||
exports = list(Path(temporary).glob("*/audit-export.json"))
|
||||
output = exports[0].read_bytes() if exports else result.stdout
|
||||
self.assertIn(b'{"fixture":true}', output)
|
||||
|
||||
def test_audit_export_creates_a_private_new_directory_and_file(self):
|
||||
step = next(step for step in recipes("AUDIT_LOGGING") if "/api/audit/export" in step)
|
||||
with recording_server() as (port, _), tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
for _ in range(2):
|
||||
result = exercise_curl(self, home, f"X-API-Token: {TEST_TOKEN}", port, step)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
output = Path(result.stdout.decode().strip().removeprefix("Saved private export to "))
|
||||
self.assertEqual(output.parent.parent, home)
|
||||
self.assertEqual(stat.S_IMODE(output.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
|
||||
self.assertEqual(json.loads(output.read_bytes()), {"fixture": True})
|
||||
self.assertEqual(len(list(home.glob("*/audit-export.json"))), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise documented agent setup without installing a service or real token.
|
||||
|
||||
TLS listeners are guest-local: run with pulse-worker-source-proof. Windows
|
||||
checks cover the documented source contract, not native ACL/service execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_pbs_docs import certificate, fixture_environment, server
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
NAMES = ("UNIFIED_AGENT.md", "TEMPERATURE_MONITORING.md")
|
||||
TOKEN = "synthetic-agent-docs-secret"
|
||||
CLEANUP_PATH = "/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh"
|
||||
|
||||
|
||||
def blocks(name, language="bash"):
|
||||
text = re.sub(r"^ {1,3}", "", (ROOT / "docs" / name).read_text(), flags=re.MULTILINE)
|
||||
return re.findall(r"```" + language + r"\n(.*?)```", text, re.DOTALL)
|
||||
|
||||
|
||||
def recipe(name, needle):
|
||||
found = [block for block in blocks(name) if needle in block]
|
||||
if len(found) != 1:
|
||||
raise AssertionError(f"expected one {name} recipe containing {needle!r}")
|
||||
return found[0]
|
||||
|
||||
|
||||
class AgentDocsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.cert, cls.key = certificate(Path(cls.temporary.name), "agent-docs")
|
||||
cls.other_cert, _ = certificate(Path(cls.temporary.name), "untrusted")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_mirrors_and_no_secret_or_unchecked_bootstrap_commands(self):
|
||||
for name in NAMES:
|
||||
with self.subTest(name=name):
|
||||
doc = (ROOT / "docs" / name).read_bytes()
|
||||
self.assertEqual(doc, (ROOT / "frontend-modern/public/docs" / name).read_bytes())
|
||||
shell = "\n".join(blocks(name))
|
||||
self.assertNotRegex(shell, r"--token(?:\s|=)|PULSE_TOKEN=|curl[^\n]*http://")
|
||||
self.assertNotRegex(shell, r"curl[^`]*\|\s*(?:sudo\s+)?bash")
|
||||
self.assertIn("Stop if the download fails", doc.decode())
|
||||
windows = "\n".join(blocks(NAMES[0], "powershell"))
|
||||
self.assertNotRegex(windows, r"(?i)\biex\b|\birm\b|Invoke-Expression|http://|\$env:PULSE_TOKEN\s*=")
|
||||
|
||||
def test_changed_unix_recipes_parse(self):
|
||||
count = 0
|
||||
for name in NAMES:
|
||||
for block in blocks(name):
|
||||
if any(word in block for word in ("agent-install.sh", "agent-token", "sensor-proxy-uninstall.sh")):
|
||||
with self.subTest(name=name, command=block):
|
||||
subprocess.run(["bash", "-n", "-c", block], check=True, capture_output=True)
|
||||
count += 1
|
||||
self.assertEqual(count, 18)
|
||||
|
||||
def test_private_file_preparation_preserves_existing_token(self):
|
||||
command = recipe(NAMES[0], 'vi "$HOME/.config/pulse/agent-token"')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
tools = home / "tools"
|
||||
tools.mkdir()
|
||||
editor = tools / "vi"
|
||||
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n')
|
||||
editor.chmod(0o700)
|
||||
env = fixture_environment(home)
|
||||
env["PATH"] = f"{tools}:{os.environ['PATH']}"
|
||||
token = home / ".config/pulse/agent-token"
|
||||
for existing in (False, True):
|
||||
if existing:
|
||||
token.write_text(TOKEN + "\n")
|
||||
token.chmod(0o644)
|
||||
token.parent.chmod(0o755)
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(stat.S_IMODE(token.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(token.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(token.read_text(), TOKEN + "\n" if existing else "")
|
||||
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
|
||||
|
||||
def test_windows_file_handoff_matches_installer_contract(self):
|
||||
windows = "\n".join(blocks(NAMES[0], "powershell"))
|
||||
for expected in ("-ErrorAction Stop", "/inheritance:r", "*${userSid}:(OI)(CI)F",
|
||||
"*S-1-5-18:(OI)(CI)F", "$LASTEXITCODE -ne 0",
|
||||
"-FilePath notepad.exe", "-Wait", "-OutFile $installerFile",
|
||||
"Remove-Item Env:PULSE_TOKEN", "-TokenFile $tokenFile"):
|
||||
self.assertIn(expected, windows)
|
||||
source = (ROOT / "scripts/install.ps1").read_text()
|
||||
self.assertIn('[string]$TokenFile = $env:PULSE_TOKEN_FILE,', source)
|
||||
self.assertIn('if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile))', source)
|
||||
self.assertIn('(Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()', source)
|
||||
|
||||
def download(self, home, command, port, ca=None, hostname="localhost"):
|
||||
private = home / ".config/pulse"
|
||||
private.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
tools = home / "tools"
|
||||
tools.mkdir(exist_ok=True)
|
||||
recorder = tools / "curl"
|
||||
recorder.write_text(
|
||||
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
|
||||
"Path(os.environ['CURL_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
|
||||
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
|
||||
)
|
||||
recorder.chmod(0o700)
|
||||
env = fixture_environment(home)
|
||||
env.update(PATH=f"{tools}:{os.environ['PATH']}", REAL_CURL=shutil.which("curl"),
|
||||
CURL_RECEIPT=str(home / "curl-argv.json"))
|
||||
command = command.replace("https://pulse.example.com", f"https://{hostname}:{port}")
|
||||
command = command.replace("https://raw.githubusercontent.com", f"https://{hostname}:{port}")
|
||||
if ca is not None:
|
||||
command = command.replace("curl ", f'curl --cacert "{ca}" ', 1)
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=20)
|
||||
argv = json.loads((home / "curl-argv.json").read_text())
|
||||
self.assertNotIn(TOKEN, " ".join(argv))
|
||||
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
|
||||
for argument in ("--connect-timeout", "--max-time", "--fail", "--output"):
|
||||
self.assertIn(argument, argv)
|
||||
self.assertNotIn("--insecure", argv)
|
||||
self.assertNotIn("-k", argv)
|
||||
return result
|
||||
|
||||
def test_downloads_require_verified_tls_and_success(self):
|
||||
for name, path, needle, output in (
|
||||
(NAMES[0], "/install.sh", '--output "$HOME/.config/pulse/agent-install.sh"', "agent-install.sh"),
|
||||
(NAMES[1], CLEANUP_PATH, '--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh"', "sensor-proxy-uninstall.sh"),
|
||||
):
|
||||
for status, ca, hostname, expected in (
|
||||
(200, self.cert, "localhost", 0),
|
||||
(403, self.cert, "localhost", 22),
|
||||
(200, None, "localhost", 60),
|
||||
(200, self.other_cert, "localhost", 60),
|
||||
(200, self.cert, "127.0.0.1", 60),
|
||||
):
|
||||
with self.subTest(name=name, status=status, expected=expected):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
with server(self.cert, self.key, status, b"# harmless installer fixture\n", path) as (port, requests):
|
||||
home = Path(temporary)
|
||||
result = self.download(home, recipe(name, needle), port, ca, hostname)
|
||||
self.assertEqual(result.returncode, expected, result.stderr.decode())
|
||||
if expected == 0:
|
||||
self.assertEqual((home / ".config/pulse" / output).read_bytes(), b"# harmless installer fixture\n")
|
||||
if expected == 60:
|
||||
self.assertEqual(requests, [])
|
||||
for requested_path, headers in requests:
|
||||
self.assertEqual(requested_path, path)
|
||||
self.assertNotIn("Authorization", headers)
|
||||
|
||||
def test_every_profile_passes_only_private_file_not_token_arguments(self):
|
||||
profiles = []
|
||||
for name in NAMES:
|
||||
profiles.extend(block for block in blocks(name) if 'bash "$HOME/.config/pulse/agent-install.sh"' in block and "--token-file" in block)
|
||||
self.assertEqual(len(profiles), 11)
|
||||
fixture = '''#!/bin/bash
|
||||
python3 - "$@" <<'PY'
|
||||
import json, os, stat, sys
|
||||
from pathlib import Path
|
||||
args = sys.argv[1:]
|
||||
token = Path(args[args.index('--token-file') + 1])
|
||||
assert args.count('--token-file') == 1
|
||||
assert token.read_text().strip() == os.environ['EXPECTED_TOKEN']
|
||||
assert stat.S_IMODE(token.stat().st_mode) == 0o600
|
||||
assert os.environ['EXPECTED_TOKEN'] not in ' '.join(args)
|
||||
assert '--token' not in args
|
||||
Path(os.environ['INSTALL_RECEIPT']).write_text(json.dumps(args))
|
||||
PY
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
private = home / ".config/pulse"
|
||||
private.mkdir(parents=True, mode=0o700)
|
||||
(private / "agent-install.sh").write_text(fixture)
|
||||
token = private / "agent-token"
|
||||
token.write_text(TOKEN + "\n")
|
||||
token.chmod(0o600)
|
||||
env = fixture_environment(home)
|
||||
env.update(EXPECTED_TOKEN=TOKEN, INSTALL_RECEIPT=str(home / "installer-argv.json"))
|
||||
for command in profiles:
|
||||
with self.subTest(command=command):
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
args = json.loads((home / "installer-argv.json").read_text())
|
||||
self.assertEqual(args[:4], ["--url", "https://pulse.example.com", "--token-file", str(token)])
|
||||
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
|
||||
expected_profiles = [[], ["--enable-proxmox", "--proxmox-type", "pve"],
|
||||
["--enable-proxmox", "--proxmox-type", "pbs"],
|
||||
["--enable-docker"], ["--enable-docker=false"],
|
||||
["--enable-kubernetes"], ["--enable-host=false", "--enable-docker"],
|
||||
["--disable-auto-update"], ["--enable-proxmox"]]
|
||||
actual_profiles = [re.findall(r"--(?:enable-[a-z]+(?:=false)?|disable-auto-update|proxmox-type\s+(?:pve|pbs))", p) for p in profiles]
|
||||
for expected in expected_profiles:
|
||||
# Compare the actual argv tokens, retaining the type's value.
|
||||
self.assertIn(" ".join(expected), [" ".join(a) for a in actual_profiles])
|
||||
source = (ROOT / "scripts/install.sh").read_text()
|
||||
self.assertIn('--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;', source)
|
||||
self.assertIn('read_collector_token_file_safely "$TOKEN_FILE_PATH" true', source)
|
||||
|
||||
def test_retarget_uninstall_and_cleanup_use_saved_script_without_new_token(self):
|
||||
expected = (["--retarget", "--url", "https://pulse.example.com"], ["--uninstall"],
|
||||
["--uninstall", "--purge", "--local-only"],
|
||||
["--uninstall", "--purge", "--remove-proxmox-access", "--local-only"])
|
||||
commands = [recipe(NAMES[0], "--retarget --url"), recipe(NAMES[0], 'agent-install.sh" --uninstall'),
|
||||
recipe(NAMES[1], "--uninstall --purge --local-only"),
|
||||
recipe(NAMES[1], "--uninstall --purge --remove-proxmox-access")]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
private = home / ".config/pulse"
|
||||
private.mkdir(parents=True, mode=0o700)
|
||||
fixture = '#!/bin/bash\npython3 - "$@" <<\'PY\'\nimport json, os, sys\nfrom pathlib import Path\nPath(os.environ["INSTALL_RECEIPT"]).write_text(json.dumps(sys.argv[1:]))\nPY\n'
|
||||
for name in ("agent-install.sh", "sensor-proxy-uninstall.sh"):
|
||||
(private / name).write_text(fixture)
|
||||
env = fixture_environment(home)
|
||||
env["INSTALL_RECEIPT"] = str(home / "argv.json")
|
||||
for command, args in zip(commands, expected):
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(json.loads((home / "argv.json").read_text()), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,17 +37,53 @@ def commands() -> tuple[str, str]:
|
||||
return preparation, request
|
||||
|
||||
|
||||
def recipe(path: str) -> str:
|
||||
matches = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
|
||||
if path in block]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"expected one executable recipe for {path}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
# Independently expected HTTP operations: extracting the shell from the guide
|
||||
# ensures the fixture tests what readers copy, not a second hand-written client.
|
||||
FLEET_ACTION_REQUESTS = (
|
||||
("GET", "/api/connections", None),
|
||||
("GET", "/api/agent/resource-capabilities/vm%3A42", None),
|
||||
("POST", "/api/actions/plan", {
|
||||
"requestId": "manual-recovery-123", "resourceId": "vm:42",
|
||||
"capabilityName": "restart", "params": {"mode": "graceful"},
|
||||
"reason": "Recover after confirmed outage",
|
||||
}),
|
||||
("POST", "/api/actions/act_.../decision", {
|
||||
"outcome": "approved", "reason": "Inside maintenance window", "planHash": "sha256:...",
|
||||
}),
|
||||
("POST", "/api/actions/act_.../execute", {
|
||||
"reason": "Execute approved recovery", "planHash": "sha256:...",
|
||||
}),
|
||||
("GET", "/api/audit/actions?resourceId=vm%3A42&limit=10", None),
|
||||
("GET", "/api/audit/actions/act_.../events", None),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def recording_server(status: int = 200):
|
||||
requests = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append((self.path, dict(self.headers)))
|
||||
def record_request(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
requests.append((self.path, dict(self.headers), self.command, body))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"fixture":true}\n')
|
||||
|
||||
do_GET = record_request
|
||||
do_POST = record_request
|
||||
do_PUT = record_request
|
||||
do_PATCH = record_request
|
||||
do_DELETE = record_request
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
@@ -62,6 +98,48 @@ def recording_server(status: int = 200):
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def exercise_curl(case: unittest.TestCase, home: Path, header_text: str, port: int, request: str):
|
||||
"""Execute the exact copied recipe; capture argv and hostile curl defaults."""
|
||||
header = home / ".config/pulse/api-header"
|
||||
header.parent.mkdir(parents=True, exist_ok=True)
|
||||
header.write_text(header_text + "\n")
|
||||
header.chmod(0o600)
|
||||
real_curl = shutil.which("curl")
|
||||
case.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
|
||||
tools = home / "tools"
|
||||
tools.mkdir(exist_ok=True)
|
||||
recorder = tools / "curl"
|
||||
recorder.write_text(
|
||||
"#!/usr/bin/env python3\nimport json, os, sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
|
||||
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
|
||||
)
|
||||
recorder.chmod(0o700)
|
||||
receipt = home / "argv.json"
|
||||
trace = home / "curl-trace.txt"
|
||||
# An existing curl configuration must not turn safe argv into a trace
|
||||
# containing the header file's credential, or inject another header.
|
||||
(home / ".curlrc").write_text(
|
||||
f'header = "X-Curlrc-Injected: yes"\nverbose\ntrace-ascii = "{trace}"\n'
|
||||
)
|
||||
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
|
||||
CURL_HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), TMPDIR=str(home),
|
||||
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
|
||||
for key in list(env):
|
||||
if key.lower().endswith("_proxy"):
|
||||
del env[key]
|
||||
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
|
||||
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
|
||||
argv = json.loads(receipt.read_text())
|
||||
case.assertEqual(argv[0], "--disable", "curl defaults must be disabled by the first option")
|
||||
case.assertNotIn(TEST_TOKEN, " ".join(argv))
|
||||
case.assertIn("@" + str(header), argv)
|
||||
case.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
|
||||
case.assertFalse(trace.exists(), "local curl configuration must not create a credential trace")
|
||||
return result
|
||||
|
||||
|
||||
class APIAuthDocsTest(unittest.TestCase):
|
||||
def test_credentials_are_not_documented_as_command_arguments(self):
|
||||
section = auth_section()
|
||||
@@ -73,6 +151,30 @@ class APIAuthDocsTest(unittest.TestCase):
|
||||
self.assertIn("monitoring:read", section)
|
||||
self.assertIn("Do not paste tokens", section)
|
||||
|
||||
def test_all_shell_recipes_keep_credentials_out_of_arguments_and_defaults(self):
|
||||
bash = "\n".join(re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL))
|
||||
self.assertNotRegex(bash, r"(?:\b\w*TOKEN=|X-API-Token:|Authorization:|Bearer\s)")
|
||||
self.assertNotRegex(bash, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)")
|
||||
requests = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
|
||||
if block.startswith("curl ")]
|
||||
self.assertEqual(len(requests), 8)
|
||||
for request in requests:
|
||||
self.assertTrue(request.startswith("curl --disable --fail-with-body "))
|
||||
self.assertIn('--header "@$HOME/.config/pulse/api-header"', request)
|
||||
if "--request POST" in request:
|
||||
self.assertIn("--data-binary @-", request)
|
||||
self.assertIn("<<'JSON'", request)
|
||||
|
||||
def test_action_guidance_preserves_scope_and_review_boundaries(self):
|
||||
guide = DOC.read_text()
|
||||
for scope in ("monitoring:read", "settings:read", "actions:plan", "actions:approve",
|
||||
"actions:execute", "audit:read"):
|
||||
self.assertIn(scope, guide)
|
||||
for boundary in ("steps separately", "bound to an authorised user", "licensed audit logging",
|
||||
"separation of duties", "step-up", "losing the connection",
|
||||
"terminal result", "planHash", "URL-encode"):
|
||||
self.assertIn(boundary, guide)
|
||||
|
||||
def test_shipped_copy_is_identical(self):
|
||||
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/API.md").read_bytes())
|
||||
|
||||
@@ -97,34 +199,8 @@ class APIAuthDocsTest(unittest.TestCase):
|
||||
self.assertEqual(stat.S_IMODE(header.stat().st_mode), 0o600)
|
||||
self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "")
|
||||
|
||||
def run_documented_request(self, home: Path, header_text: str, port: int):
|
||||
_, request = commands()
|
||||
header = home / ".config/pulse/api-header"
|
||||
header.parent.mkdir(parents=True, exist_ok=True)
|
||||
header.write_text(header_text + "\n")
|
||||
header.chmod(0o600)
|
||||
real_curl = shutil.which("curl")
|
||||
self.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
|
||||
tools = home / "tools"
|
||||
tools.mkdir(exist_ok=True)
|
||||
recorder = tools / "curl"
|
||||
recorder.write_text(
|
||||
"#!/usr/bin/env python3\nimport json, os, sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
|
||||
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
|
||||
)
|
||||
recorder.chmod(0o700)
|
||||
receipt = home / "argv.json"
|
||||
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
|
||||
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
|
||||
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
|
||||
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
|
||||
argv = json.loads(receipt.read_text())
|
||||
self.assertNotIn(TEST_TOKEN, " ".join(argv))
|
||||
self.assertIn("@" + str(header), argv)
|
||||
self.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
|
||||
return result
|
||||
def run_documented_request(self, home: Path, header_text: str, port: int, request=None):
|
||||
return exercise_curl(self, home, header_text, port, request if request is not None else commands()[1])
|
||||
|
||||
def test_header_file_sends_each_supported_header_without_exposing_argv(self):
|
||||
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):
|
||||
@@ -134,6 +210,41 @@ class APIAuthDocsTest(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(requests[-1][0], "/api/state/summary")
|
||||
self.assertEqual(requests[-1][1][key], value)
|
||||
self.assertNotIn("X-Curlrc-Injected", requests[-1][1])
|
||||
|
||||
def test_fleet_action_recipes_send_expected_methods_paths_and_json(self):
|
||||
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):
|
||||
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
|
||||
for method, path, body in FLEET_ACTION_REQUESTS:
|
||||
with self.subTest(header=key, method=method, path=path):
|
||||
before = len(requests)
|
||||
result = self.run_documented_request(Path(temporary), f"{key}: {value}", port,
|
||||
recipe(path))
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(len(requests), before + 1, "each step sends only its own request")
|
||||
sent_path, headers, sent_method, sent_body = requests[-1]
|
||||
self.assertEqual((sent_method, sent_path), (method, path))
|
||||
self.assertEqual(headers[key], value)
|
||||
other_key = "Authorization" if key == "X-API-Token" else "X-API-Token"
|
||||
self.assertNotIn(other_key, headers)
|
||||
self.assertNotIn("X-Curlrc-Injected", headers)
|
||||
if body is None:
|
||||
self.assertEqual(sent_body, b"")
|
||||
else:
|
||||
self.assertEqual(headers["Content-Type"], "application/json")
|
||||
self.assertEqual(json.loads(sent_body), body)
|
||||
|
||||
def test_each_fleet_action_recipe_surfaces_auth_errors_without_following_steps(self):
|
||||
for status in (401, 403):
|
||||
with tempfile.TemporaryDirectory() as temporary, recording_server(status) as (port, requests):
|
||||
for _method, path, _body in FLEET_ACTION_REQUESTS:
|
||||
with self.subTest(status=status, path=path):
|
||||
before = len(requests)
|
||||
result = self.run_documented_request(Path(temporary), "X-API-Token: " + TEST_TOKEN,
|
||||
port, recipe(path))
|
||||
self.assertEqual(result.returncode, 22, result.stderr.decode())
|
||||
self.assertIn(b'{"fixture":true}', result.stdout)
|
||||
self.assertEqual(len(requests), before + 1)
|
||||
|
||||
def test_http_auth_errors_fail_instead_of_looking_successful(self):
|
||||
for status in (401, 403):
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the PBS guide's recipes with synthetic tokens and guest-local TLS.
|
||||
|
||||
No real PBS/Pulse instance, installer, service or credential is used. Run the
|
||||
loopback checks with pulse-worker-source-proof, not on the worker host.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOC = ROOT / "docs/PBS.md"
|
||||
PBS_TOKEN = "synthetic-pbs-secret"
|
||||
AGENT_TOKEN = "synthetic-agent-secret"
|
||||
AUTH_HEADER = f"PBSAPIToken=pulse-monitor@pbs!pulse-token:{PBS_TOKEN}"
|
||||
|
||||
|
||||
def blocks():
|
||||
# Nested list-item fences have two leading spaces, which are not shell
|
||||
# input when copied from the rendered guide.
|
||||
text = re.sub(r"^ ", "", DOC.read_text(), flags=re.MULTILINE)
|
||||
return re.findall(r"```bash\n(.*?)```", text, re.DOTALL)
|
||||
|
||||
|
||||
def recipe(needle):
|
||||
matches = [block for block in blocks() if needle in block]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"expected one recipe containing {needle!r}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def fixture_environment(home):
|
||||
env = dict(os.environ, HOME=str(home))
|
||||
# Keep all requests guest-local even if the proof runtime has proxy vars.
|
||||
for key in list(env):
|
||||
if key.lower().endswith("_proxy"):
|
||||
del env[key]
|
||||
return env
|
||||
|
||||
|
||||
def certificate(directory, name):
|
||||
certificate = directory / f"{name}.pem"
|
||||
key = directory / f"{name}.key"
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(key), "-out", str(certificate), "-days", "1",
|
||||
"-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost",
|
||||
], check=True, capture_output=True)
|
||||
key.chmod(0o600)
|
||||
return certificate, key
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server(cert, key, status=200, installer=None, installer_path="/install.sh"):
|
||||
requests = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append((self.path, dict(self.headers)))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
body = installer if self.path == installer_path else b'{"data":[]}\n'
|
||||
self.wfile.write(body or b"fixture error\n")
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
http = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain(str(cert), str(key))
|
||||
http.socket = context.wrap_socket(http.socket, server_side=True)
|
||||
thread = threading.Thread(target=http.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield http.server_address[1], requests
|
||||
finally:
|
||||
http.shutdown()
|
||||
http.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
class PBSDocsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.cert, cls.key = certificate(Path(cls.temporary.name), "server")
|
||||
cls.other_cert, _ = certificate(Path(cls.temporary.name), "other")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_shipped_copy_and_safe_scope(self):
|
||||
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/PBS.md").read_bytes())
|
||||
text = DOC.read_text()
|
||||
shell = "\n".join(blocks())
|
||||
self.assertNotRegex(shell, r"Authorization:|PBSAPIToken=|PULSE_SETUP_TOKEN=|--token(?:\s|=)|--insecure|curl\s+-[^\s]*k")
|
||||
self.assertNotIn("/api2/json/version", shell)
|
||||
self.assertNotIn("disable SSL verification", text)
|
||||
for phrase in ("both the user and the token", "Manual Token Setup",
|
||||
"not the **PBS** token", "independently verified",
|
||||
"An agent is not", "empty list is not proof",
|
||||
"Do not delete backups", "Do not clear History"):
|
||||
self.assertIn(phrase, text)
|
||||
|
||||
def test_every_documented_shell_recipe_parses(self):
|
||||
for command in blocks():
|
||||
with self.subTest(command=command):
|
||||
subprocess.run(["bash", "-n", "-c", command], check=True, capture_output=True)
|
||||
|
||||
def test_new_setup_grants_audit_to_user_and_token(self):
|
||||
command = recipe("proxmox-backup-manager user create")
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
tool = home / "proxmox-backup-manager"
|
||||
tool.write_text(
|
||||
"#!/usr/bin/env python3\nimport json, os, sys\n"
|
||||
"with open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n"
|
||||
)
|
||||
tool.chmod(0o700)
|
||||
env = fixture_environment(home)
|
||||
env.update(PATH=f"{home}:{os.environ['PATH']}", CALLS=str(home / "calls.jsonl"))
|
||||
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
|
||||
calls = [json.loads(line) for line in (home / "calls.jsonl").read_text().splitlines()]
|
||||
self.assertEqual(calls, [
|
||||
["user", "create", "pulse-monitor@pbs", "--comment", "Pulse monitoring"],
|
||||
["user", "generate-token", "pulse-monitor@pbs", "pulse-token"],
|
||||
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs"],
|
||||
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs!pulse-token"],
|
||||
])
|
||||
# Same permission shapes as the owning implementation, not a claim of
|
||||
# running proxmox-backup-manager on a real appliance.
|
||||
source = (ROOT / "internal/api/configapi/setup_script_render.go").read_text()
|
||||
self.assertIn("proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs", source)
|
||||
self.assertIn('proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"', source)
|
||||
|
||||
def test_private_preparation_preserves_existing_content(self):
|
||||
for name in ("pbs-agent-token", "pbs-header"):
|
||||
command = recipe(f'vi "$HOME/.config/pulse/{name}"')
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
|
||||
home = Path(temporary)
|
||||
tools = home / "tools"
|
||||
tools.mkdir()
|
||||
editor = tools / "vi"
|
||||
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n')
|
||||
editor.chmod(0o700)
|
||||
env = fixture_environment(home)
|
||||
env["PATH"] = f"{tools}:{os.environ['PATH']}"
|
||||
path = home / ".config/pulse" / name
|
||||
for existing in (False, True):
|
||||
if existing:
|
||||
path.write_text("synthetic-existing-content\n")
|
||||
path.chmod(0o644)
|
||||
path.parent.chmod(0o755)
|
||||
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(path.read_text(), "synthetic-existing-content\n" if existing else "")
|
||||
|
||||
def run_curl(self, home, command, port, ca=None, hostname="localhost"):
|
||||
private = home / ".config/pulse"
|
||||
private.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
header = private / "pbs-header"
|
||||
header.write_text(f"Authorization: {AUTH_HEADER}\n")
|
||||
header.chmod(0o600)
|
||||
real_curl = shutil.which("curl")
|
||||
self.assertIsNotNone(real_curl)
|
||||
tools = home / "tools"
|
||||
tools.mkdir(exist_ok=True)
|
||||
recorder = tools / "curl"
|
||||
recorder.write_text(
|
||||
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
|
||||
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
|
||||
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
|
||||
)
|
||||
recorder.chmod(0o700)
|
||||
receipt = home / "argv.json"
|
||||
env = fixture_environment(home)
|
||||
env.update(PATH=f"{tools}:{os.environ['PATH']}", REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
|
||||
command = command.replace("https://pbs.example.com:8007", f"https://{hostname}:{port}")
|
||||
command = command.replace("https://pulse.example.com", f"https://{hostname}:{port}")
|
||||
if ca is not None:
|
||||
shutil.copyfile(ca, private / "pbs-ca.pem")
|
||||
# This is the guide's optional, independently verified CA-file form.
|
||||
command = command.replace("curl ", 'curl --cacert "$HOME/.config/pulse/pbs-ca.pem" ', 1)
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=20)
|
||||
argv = json.loads(receipt.read_text())
|
||||
for secret in (PBS_TOKEN, AGENT_TOKEN):
|
||||
self.assertNotIn(secret, " ".join(argv))
|
||||
self.assertNotIn(secret.encode(), result.stdout + result.stderr)
|
||||
self.assertNotIn("--insecure", argv)
|
||||
self.assertNotIn("-k", argv)
|
||||
return result, argv
|
||||
|
||||
def test_datastore_request_transmits_auth_only_in_header(self):
|
||||
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key) as (port, requests):
|
||||
result, argv = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertIn("--max-time", argv)
|
||||
self.assertIn("--connect-timeout", argv)
|
||||
self.assertIn("@" + str(Path(temporary) / ".config/pulse/pbs-header"), argv)
|
||||
self.assertEqual(requests, [("/api2/json/admin/datastore", {
|
||||
**requests[0][1], "Authorization": AUTH_HEADER,
|
||||
})])
|
||||
self.assertEqual(result.stdout, b'{"data":[]}\n')
|
||||
|
||||
def test_auth_errors_retain_body_and_fail(self):
|
||||
for status in (401, 403):
|
||||
with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary:
|
||||
with server(self.cert, self.key, status) as (port, requests):
|
||||
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
|
||||
self.assertEqual(result.returncode, 22, result.stderr.decode())
|
||||
self.assertEqual(len(requests), 1)
|
||||
self.assertEqual(result.stdout, b'{"data":[]}\n')
|
||||
|
||||
def test_tls_rejects_untrusted_wrong_certificate_and_wrong_hostname(self):
|
||||
for ca, hostname in ((None, "localhost"), (self.other_cert, "localhost"), (self.cert, "127.0.0.1")):
|
||||
with self.subTest(ca=ca, hostname=hostname), tempfile.TemporaryDirectory() as temporary:
|
||||
with server(self.cert, self.key) as (port, requests):
|
||||
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, ca, hostname)
|
||||
self.assertEqual(result.returncode, 60, result.stderr.decode())
|
||||
# Authentication is never sent over an unverified channel.
|
||||
self.assertEqual(requests, [])
|
||||
|
||||
def test_download_and_agent_file_handoff(self):
|
||||
# The downloaded fixture deliberately cannot install or change a
|
||||
# service. It verifies only the documented argument/file handoff.
|
||||
installer = b'''#!/bin/bash
|
||||
python3 - "$@" <<'PY'
|
||||
import json, os, stat, sys
|
||||
from pathlib import Path
|
||||
args = sys.argv[1:]
|
||||
path = Path(args[args.index('--token-file') + 1])
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert path.read_text().strip() == os.environ['EXPECTED_AGENT_TOKEN']
|
||||
assert os.environ['EXPECTED_AGENT_TOKEN'] not in ' '.join(args)
|
||||
Path(os.environ['AGENT_RECEIPT']).write_text(json.dumps(args))
|
||||
PY
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, installer=installer) as (port, requests):
|
||||
home = Path(temporary)
|
||||
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(requests[0][0], "/install.sh")
|
||||
self.assertNotIn("Authorization", requests[0][1])
|
||||
token = home / ".config/pulse/pbs-agent-token"
|
||||
token.write_text(AGENT_TOKEN + "\n")
|
||||
token.chmod(0o600)
|
||||
command = recipe('bash "$HOME/.config/pulse/pbs-agent-install.sh"')
|
||||
env = fixture_environment(home)
|
||||
env.update(EXPECTED_AGENT_TOKEN=AGENT_TOKEN, AGENT_RECEIPT=str(home / "agent-argv.json"))
|
||||
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(json.loads((home / "agent-argv.json").read_text()), [
|
||||
"--url", "https://pulse.example.com", "--token-file", str(token),
|
||||
"--enable-proxmox", "--proxmox-type", "pbs", "--enable-docker=false",
|
||||
])
|
||||
self.assertNotIn(AGENT_TOKEN.encode(), result.stdout + result.stderr)
|
||||
actual = (ROOT / "scripts/install.sh").read_text()
|
||||
self.assertIn('--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;', actual)
|
||||
self.assertIn('read_collector_token_file_safely "$TOKEN_FILE_PATH" true', actual)
|
||||
|
||||
def test_failed_installer_download_is_nonzero(self):
|
||||
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, 403) as (port, _requests):
|
||||
home = Path(temporary)
|
||||
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
|
||||
self.assertEqual(result.returncode, 22, result.stderr.decode())
|
||||
self.assertFalse((home / ".config/pulse/pbs-agent-install.sh").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user