Merge pull request #2019 from rcourtman/maintainer/20260909T233300Z

Preserve import previews and PBS history while updating dependencies
This commit is contained in:
pulse-triage[bot]
2026-09-10 02:12:41 +00:00
committed by GitHub
39 changed files with 1151 additions and 186 deletions
@@ -462,7 +462,7 @@ jobs:
- name: Submit SignPath Authenticode request
if: ${{ inputs.windows_signing_backend == 'signpath' }}
id: signpath
uses: signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2
uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
@@ -719,7 +719,7 @@ jobs:
sudo apt-get install -y zip unzip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: 'v3.15.2'
@@ -937,7 +937,7 @@ jobs:
# downloaded asset without access to GitHub's attestation API.
- name: Attest complete release candidate
id: attest_release_candidate
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ${{ runner.temp }}/release-candidate-subjects.sha256
@@ -155,7 +155,7 @@ jobs:
- name: Attest hosted qualification subjects
id: attest_secure_runtime
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ${{ runner.temp }}/secure-runtime-qualification/secure-runtime-compiler-subjects.sha256
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
python3 scripts/write_github_output.py release_tag "${release_tag}"
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
+2 -2
View File
@@ -96,13 +96,13 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+13 -13
View File
@@ -118,19 +118,19 @@ jobs:
--source-sha "${EXPECTED_SOURCE_SHA}"
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -139,7 +139,7 @@ jobs:
- name: Assemble and push Pulse server image (multi-arch)
if: matrix.image == 'server'
id: build_server_image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: runtime_prebuilt
@@ -157,7 +157,7 @@ jobs:
- name: Attest Pulse server image on Docker Hub
if: matrix.image == 'server'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: docker.io/rcourtman/pulse
subject-digest: ${{ steps.build_server_image.outputs.digest }}
@@ -166,7 +166,7 @@ jobs:
- name: Attest Pulse server image on GHCR
if: matrix.image == 'server'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository_owner }}/pulse
subject-digest: ${{ steps.build_server_image.outputs.digest }}
@@ -176,7 +176,7 @@ jobs:
- name: Assemble and push Pulse control-plane image (multi-arch)
if: matrix.image == 'control-plane'
id: build_control_plane_image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: deploy/provider-msp/Dockerfile.control-plane
@@ -195,7 +195,7 @@ jobs:
- name: Attest Pulse control-plane image on Docker Hub
if: matrix.image == 'control-plane'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: docker.io/rcourtman/pulse-control-plane
subject-digest: ${{ steps.build_control_plane_image.outputs.digest }}
@@ -204,7 +204,7 @@ jobs:
- name: Attest Pulse control-plane image on GHCR
if: matrix.image == 'control-plane'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository_owner }}/pulse-control-plane
subject-digest: ${{ steps.build_control_plane_image.outputs.digest }}
@@ -249,16 +249,16 @@ jobs:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -3
View File
@@ -108,7 +108,7 @@ jobs:
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
@@ -175,7 +175,7 @@ jobs:
# chart push authenticated through Helm, and authenticate the OCI
# attestation client explicitly before it publishes provenance.
- name: Authenticate OCI attestation client with GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -203,7 +203,7 @@ jobs:
echo "chart_digest=${digests[0]}" >> "$GITHUB_OUTPUT"
- name: Attest exact-version OCI chart
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository_owner }}/pulse-chart/pulse
subject-digest: ${{ steps.push.outputs.chart_digest }}
@@ -113,7 +113,7 @@ jobs:
'
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
- name: Connect to the governed demo network
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -179,7 +179,7 @@ jobs:
echo "[OK] GitHub's stored RC packet still matches the immutable source-run manifest."
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
@@ -196,16 +196,16 @@ jobs:
"${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -44,13 +44,13 @@ jobs:
- name: Set up Helm
if: ${{ github.event.schedule != '17 */6 * * *' }}
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
- name: Set up Docker Buildx
if: ${{ github.event.schedule != '17 */6 * * *' }}
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Resolve the advertised stable release
id: release
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- name: Submit SignPath test-signing request
id: signpath
uses: signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2
uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+1 -1
View File
@@ -363,7 +363,7 @@ jobs:
- name: Tailscale
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -15,6 +15,20 @@
## Purpose
### Import preview lifetime and setup authority
The node credential editor binds each monitored-system impact preview to the
current import-plan generation. Changing that plan (including changing away
and back to the same endpoint), or disposing the editor, invalidates outstanding
requests. Obsolete success, error and completion handlers cannot update the
preview or end a newer request's loading state.
Preview completion does not approve an import or grant setup-command authority.
Plan changes still clear explicit approval; generating setup commands and saving
remain subject to the existing approval guard. Preview generation is local UI
state, not an agent identity, enrollment token or liveness signal.
### Explicit filesystem selections survive the report boundary
Disk reports carry optional `explicitlyIncluded` evidence when the collector
@@ -20,6 +20,21 @@
## Purpose
### Node import preview response ownership
The node credential editor calls the existing monitored-system-ledger preview
endpoint with the current plan's preview request. It accepts asynchronous
response state only for the request's still-current local generation. A plan
change or unmount retires that generation, including for error and loading-state
updates; returning to the same endpoint does not revive a retired request.
This is a consumer-side response-lifetime rule, not a wire-schema or server
cancellation change. Current-request errors remain visible and retryable.
A preview is informational: it cannot approve setup or mutate a connection.
The focused NodeCredentialSlot regression and settings architecture boundary
check accompany desktop/narrow browser interaction proof.
The delivery-log response redacts embedded webhook URLs in errorMessage rather than treating the entire diagnostic as a URL. Non-secret operation and failure context survive alongside notificationId, destinationId, outcome, failureClass and attempts; malformed URL diagnostics fail closed. The HTTP payload proof is TestContract_DeliveryDiagnosticPayload; notification handler tests pin plain, credential-bearing and malformed errors. No response keys or route permissions change.
### Canonical incident-history query contract
@@ -5407,3 +5407,22 @@ Retained provider delivery verification establishes archive SHA256
a valid pinned SSH signature and the extracted request expression with absent
and synthetic email. It does not establish installed onboarding, server acceptance
or legal identity. See the qualification evidence below.
### Pinned release action consumer compatibility
The grouped release actions use immutable revisions recorded in
`scripts/release_control/action_consumer_manifests.json`: Attest 4.2.2,
Helm setup 5.0.1, Docker build/push 7.3.0, login 4.6.0, Buildx/QEMU
setup 4.3.0, SignPath v2 and Tailscale 4.1.3. The snapshot records the
upstream action manifest hash, accepted inputs/outputs and Node 24 entry points.
It is reviewed input, not runtime attestation or permission to publish.
`test_reviewed_action_manifests_cover_all_release_consumers` checks every
workflow consumer against those upstream inputs and immutable revisions.
Helm remains 3.15.2; attestation keeps checksum subjects; signing keeps the
existing organisation/token interface. Existing signing, checksum, Docker
promotion and exact-dispatch contract tests remain required. Action upgrades
must not alter permissions, environments, source identity, rollback, signing
backend selection or release qualification. Local contract success does not
establish hosted action execution, signature acceptance, image publication or
production deployment.
@@ -573,6 +573,15 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
## Shared Boundaries
### PBS host history correlation
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.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks and ambiguous identities;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.
The settings panel registry supplies organisation overview, access and sharing with security-status currentUsername. An explicitly empty principal must not fall back to configured administrator identity; only older responses lacking the field use proxy/SSO/configured-username compatibility fallback. This identity plumbing must not change settings shell framing or bypass panel capability gates.
Settings navigation discoverability is part of the shared settings-shell
@@ -305,6 +305,15 @@ command-capable profile.
## Shared Boundaries
### PBS host history correlation
Backups hydrates type=pbs,agent with source=pbs and reuses Overview guest inventory. Deduplicate combined snapshots by canonical ID before correlation, avoiding false ambiguity for agents in both queries. Standalone non-PVE PBS telemetry remains available without downloading the guest estate twice.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks and ambiguous identities;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.
### Notification recovery reload ownership
Queue recovery API handlers follow router monitor replacement instead of
@@ -428,6 +428,15 @@ about the same disk cannot diverge.
## Shared Boundaries
### PBS host history correlation
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks and ambiguous identities;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.
Platform estate orientation consumes the same canonical resource projection as
the platform's tables. Pages must not issue a second summary fetch or create a
parallel metric-card projection. Unified resources owns identity, type,
@@ -968,13 +977,13 @@ container inventory table.
contract below while owning the domain-specific artifact request.
Each row carries the canonical PBS `Resource` into the shared platform
resource-detail row. When coalescence has attached an agent facet, or when
the product-family projection contains exactly one separate Agent resource
the route projection contains exactly one agent-bearing host or guest resource
with a matching normalized host identity, that same drawer must expose
host system, hardware, network, disks, thermals, history, and management
context without recreating a PBS-local host model or returning the
provider-owned resource to the standalone Machines list. Presentation-only
correlation retains the canonical PBS row id and service facet, uses the
Agent metrics target and telemetry facets, and fails closed when no Agent
canonical host/guest metrics target and telemetry facets, and fails closed when no host
matches or more than one candidate matches. It must not alter registry
identity or treat input order as correlation evidence.
13. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx` shared with `storage-recovery`: Proxmox workload coverage rows are both a storage/recovery protection-posture surface and a unified-resource identity consumer boundary.
@@ -2235,7 +2244,7 @@ because canonical merge preserves the provider source beside the agent source;
the pages must not admit every agent merely to recover those merged rows.
Proxmox route queries likewise declare their exact PVE, PBS, PMG, or agent
source boundary. Backups extend the source-scoped Overview guest snapshot with
a PBS-only snapshot, so a route transition composes canonical resources rather
a PBS-source server/agent snapshot, so a route transition composes canonical resources rather
than repeating the guest enumeration under another cache key.
TrueNAS follows the same rule: a merged agent-backed appliance retains its
`truenas` source, so the page queries that provider alone instead of admitting
+14 -20
View File
@@ -1,42 +1,36 @@
{
"version": 1,
"base_sha": "14a109444c6d1cad420797ea30f279e77b6fc517",
"verified_at": "2026-09-09T10:42:06.278902Z",
"base_sha": "1dcf4db9eaacf1cf4dc7d1629916d6218fe8abf1",
"verified_at": "2026-09-10T00:03:38.494393Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/AppLayout.tsx"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/AppLayout.tsx": "71cbc527fae0dbae3891174bb8a140d90f50917405d6c15a3b0d8ae7dcb31644"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "092c6561bf59e1485a0602603e43da1fb5f2ce2c6b0fa312a1f3fe8fa0437c31",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "0aba67a461e39a45fdddf199eb4230fbed93724f9795fa4b09c61ebc31b6715e"
},
"routes": [
"/settings/organization",
"/settings/organization/access",
"/settings/organization/sharing"
"/proxmox/backups"
],
"viewports": [
{
"width": 320,
"height": 568
"width": 1280,
"height": 844
},
{
"width": 390,
"height": 844
},
{
"width": 1280,
"height": 720
}
],
"states": [
"Signed entitled default and created organizations; non-demo Community backend",
"Access invitation and outgoing sharing fixture",
"Desktop incoming share pending then accepted"
"Authenticated source-built local backend; resource/history API fixtures; websocket disabled to isolate fixture inventory",
"Standalone PBS agent and PVE VM with agent facet; disk series absent; CPU and memory series populated"
],
"interactions": [
"Chromium and mobile WebKit: switch organization; audit three routes at both phone widths; reach shell bottom and assert page/shell width within one pixel",
"Desktop Chromium Scenario 6: verify owner identity relationships, editor/viewer roles and pending approval; click Accept and verify accepted state via API"
"Keyboard expand PBS row, select History, assert exact canonical type/id request and two utilization plot paths, collapse; repeat for both host types at both widths"
],
"command": "pulse-heavy-run -- bash -c \"cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=true node scripts/with-offline-entitlements.mjs node scripts/run-playwright.mjs tests/05-settings-mobile-audit.spec.ts --project=chromium --project=mobile-safari --grep settings/organization --workers=1 --retries=0\"; separate diagnostic config Scenario 6 desktop run",
"notes": "Exact actual mobile grep: settings/organization(/(sharing|access))?$ . Six mobile route tests passed (53.0s); desktop sharing scenario passed (3.8s). Source-built real Community backend, ephemeral loopback signing issuer, no mock mode or signature bypass. Phone Chromium/WebKit and desktop acceptance screenshots inspected. Evidence retained in Web20260909T103517Z outcome directory browser-proof, org-fixed.log and scenario6-desktop.log. Not private RBAC, hosted probation clearance or release qualification."
"command": "pulse-heavy-run -- bash -c 'cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=true node scripts/run-playwright.mjs tests/63-pbs-history-targets.spec.ts --project=chromium --workers=1 --retries=0'",
"notes": "Two final Chromium tests pass (3.2s); desktop and narrow screenshots inspected. Synthetic API proof, not reporter/PBS installation acceptance. First attempt retained: live snapshots overwrote fixtures, and narrow pointer target was intercepted by row text; final fixture isolates websocket and uses supported keyboard expansion. Proof retained in Web20260909T235527Z outcome directory."
}
@@ -1,4 +1,4 @@
import { Component, createEffect, createMemo, createSignal } from 'solid-js';
import { Component, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import {
MonitoredSystemLedgerAPI,
type MonitoredSystemLedgerPreviewResponse,
@@ -103,6 +103,10 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
buildNodeImportPlan(props.nodeType, state.formData(), props.importCandidate),
);
let previewGeneration = 0;
onCleanup(() => {
previewGeneration += 1;
});
let previousImportPlanSignature = '';
createEffect(() => {
@@ -111,6 +115,9 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
return;
}
previousImportPlanSignature = signature;
// Pending results belong to the old plan, including an A → B → A edit.
previewGeneration += 1;
setPreviewingImportImpact(false);
setImportApproved(false);
setImportImpactPreview(null);
setImportImpactPreviewError(null);
@@ -125,19 +132,22 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
return;
}
const generation = ++previewGeneration;
setPreviewingImportImpact(true);
setImportImpactPreviewError(null);
try {
const preview = await MonitoredSystemLedgerAPI.preview(plan.previewRequest);
if (generation !== previewGeneration) return;
setImportImpactPreview(preview);
} catch (error) {
if (generation !== previewGeneration) return;
logger.error('[Infrastructure Import Plan] Impact preview failed', error);
setImportImpactPreview(null);
setImportImpactPreviewError(
error instanceof Error ? error.message : 'Unable to calculate import impact right now.',
);
} finally {
setPreviewingImportImpact(false);
if (generation === previewGeneration) setPreviewingImportImpact(false);
}
};
@@ -3,6 +3,7 @@ import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InfrastructurePlatformSettingsProps } from '@/components/Settings/proxmoxSettingsModel';
import type { NodeConfigWithStatus } from '@/types/nodes';
import { MonitoredSystemLedgerAPI } from '@/api/monitoredSystemLedger';
import { NodeCredentialSlot } from '../NodeCredentialSlot';
const importApprovalRequiredMessage =
@@ -20,7 +21,10 @@ const createSettings = (): InfrastructurePlatformSettingsProps =>
}) as unknown as InfrastructurePlatformSettingsProps;
describe('NodeCredentialSlot', () => {
afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('syncs an untouched mounted editor to a refreshed node snapshot', async () => {
const settings = createSettings();
@@ -160,6 +164,43 @@ describe('NodeCredentialSlot', () => {
expect(settings.saveNode).not.toHaveBeenCalled();
});
it('discards an obsolete preview error after the endpoint changes', async () => {
let rejectPreview!: (error: Error) => void;
const pending = new Promise<never>((_, reject) => {
rejectPreview = reject;
});
vi.spyOn(MonitoredSystemLedgerAPI, 'preview').mockReturnValueOnce(pending);
render(() => (
<NodeCredentialSlot
nodeType="pve"
settings={createSettings()}
prefillNode={{ name: 'tower', host: 'https://tower.local:8006' }}
importCandidate={{
kind: 'discovery',
server: {
type: 'pve',
ip: '10.0.0.10',
hostname: 'tower.local',
port: 8006,
version: '9.0',
},
}}
onCancel={vi.fn()}
onSaved={vi.fn()}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Preview impact' }));
fireEvent.input(screen.getByLabelText(/^Endpoint URL/), {
target: { value: 'https://replacement.local:8006' },
});
rejectPreview(new Error('Obsolete endpoint failure'));
await pending.catch(() => undefined);
await waitFor(() => {
expect(screen.queryByText('Obsolete endpoint failure')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview impact' })).toBeEnabled();
});
});
it('requires candidate import plan approval before guided setup handoff or manual save', () => {
render(() => (
<NodeCredentialSlot
@@ -2094,3 +2094,20 @@ describe('organisation principal shell boundary', () => {
}
});
});
describe('node import preview authority boundary', () => {
it('keeps preview responses informational and setup approval explicit', () => {
const previewHandler = nodeCredentialSlotSource.slice(
nodeCredentialSlotSource.indexOf('const handlePreviewImportImpact'),
nodeCredentialSlotSource.indexOf('const handleSubmit'),
);
expect(previewHandler).toContain('MonitoredSystemLedgerAPI.preview(plan.previewRequest)');
expect(previewHandler).not.toContain('setImportApproved');
expect(previewHandler).not.toContain('saveNode');
expect(previewHandler).not.toContain('handleSubmit');
expect(nodeCredentialSlotSource).toContain(
'setupHandoffDisabled: () => Boolean(props.importCandidate && !importApproved())',
);
expect(nodeCredentialSlotSource).toContain('if (saveBlockedByImportPlan())');
});
});
@@ -133,7 +133,12 @@ const uniquelyCorrelatedAgent = (
const serverTokens = identityTokens(server);
if (serverTokens.size === 0) return undefined;
const matches = candidates.filter((candidate) => {
if (candidate.type !== 'agent') return false;
// Host telemetry can be merged into a PVE guest rather than a standalone
// agent. Keep the unique-identity check and require an actual agent facet.
const guestWithAgent =
(candidate.type === 'vm' || candidate.type === 'system-container') &&
Boolean(candidate.agent ?? candidate.platformData?.agent);
if (candidate.type !== 'agent' && !guestWithAgent) return false;
for (const token of identityTokens(candidate)) {
if (serverTokens.has(token)) return true;
}
@@ -66,13 +66,13 @@ import {
} from './proxmoxPageModel';
// Each workflow hydrates only the source-scoped resource families it consumes.
// Backups reuse the Overview guest snapshot and add only PBS rows, so opening
// that workflow never downloads the same large guest inventory twice.
// Backups reuse the Overview guest snapshot and add PBS servers and standalone
// host telemetry, without downloading the same large guest inventory twice.
const PROXMOX_RESOURCE_QUERY_BY_TAB: Record<ProxmoxPageTabId, string> = {
overview: 'type=agent,vm,system-container,oci-container&source=proxmox',
storage: 'type=agent,pbs,storage,physical_disk,ceph&source=proxmox,pbs,agent',
replication: 'type=agent&source=proxmox',
backups: 'type=pbs&source=pbs',
backups: 'type=pbs,agent&source=pbs',
ceph: 'type=ceph&source=proxmox',
mail: 'type=pmg&source=pmg',
};
@@ -223,10 +223,16 @@ export function ProxmoxPageSurface() {
buildProxmoxPageModel(normalizeSnapshot(snapshot));
const overviewModel = createMemo(() => buildModel(overviewResources.resources()));
const backupModel = createMemo(() =>
buildModel([
...normalizeSnapshot(overviewResources.resources()),
...normalizeSnapshot(backupResources.resources()),
]),
buildModel(
Array.from(
new Map(
[
...normalizeSnapshot(overviewResources.resources()),
...normalizeSnapshot(backupResources.resources()),
].map((resource) => [resource.id, resource]),
).values(),
),
),
);
const model = createMemo(() => {
if (activeTab() === 'overview') return overviewModel();
@@ -389,7 +395,7 @@ export function ProxmoxPageSurface() {
<ProxmoxBackupsTable
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
workloads={model().guests}
servers={model().pbs}
servers={model().resources}
/>
</Show>
<Show when={activeTab() === 'ceph'}>
@@ -21,6 +21,7 @@ vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
data-host-details-open={String(props.initialShowHostDetails === true)}
data-agent-id={props.resource.agent?.agentId}
data-metrics-resource-id={props.resource.metricsTarget?.resourceId}
data-metrics-resource-type={props.resource.metricsTarget?.resourceType}
>
<button type="button" onClick={props.onClose}>
Close details
@@ -95,74 +96,102 @@ describe('ProxmoxBackupServersTable details', () => {
expect(screen.queryByTestId('pbs-resource-detail')).not.toBeInTheDocument();
});
it('uses the uniquely correlated agent resource for host details and metrics history', () => {
const pbs = makePbsResource();
pbs.sources = ['pbs'];
pbs.agent = undefined;
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
pbs.platformData = {
sources: ['pbs'],
pbs: { instanceId: 'pbs-main', hostname: 'pbs-main', datastoreCount: 1 },
};
const agent = {
id: 'agent-host-1',
type: 'agent',
name: 'pbs-main.local',
displayName: 'PBS host',
platformId: 'agent-host-1',
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: pbs.lastSeen + 1_000,
agent: { agentId: 'agent-pbs-1', hostname: 'pbs-main.local', osName: 'Debian GNU/Linux' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' },
platformData: {
sources: ['agent', 'pbs'],
agent: { agentId: 'agent-pbs-1', hostname: 'pbs-main.local' },
},
} as Resource;
render(() => <ProxmoxBackupServersTable servers={[pbs, agent]} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
const detail = screen.getByTestId('pbs-resource-detail');
expect(detail).toHaveAttribute('data-resource-id', 'pbs-1');
expect(detail).toHaveAttribute('data-agent-id', 'agent-pbs-1');
expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1');
});
it('does not guess when two agent resources share the PBS hostname', () => {
const pbs = makePbsResource();
pbs.sources = ['pbs'];
pbs.agent = undefined;
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
const candidate = (id: string): Resource =>
({
id,
type: 'agent',
it.each(['agent', 'vm', 'system-container'] as const)(
'uses the uniquely correlated %s resource for host details and metrics history',
(type) => {
const pbs = makePbsResource();
pbs.sources = ['pbs'];
pbs.agent = undefined;
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
pbs.platformData = {
sources: ['pbs'],
pbs: { instanceId: 'pbs-main', hostname: 'pbs-main', datastoreCount: 1 },
};
const agent = {
id: 'agent-host-1',
type,
name: 'pbs-main.local',
displayName: id,
platformId: id,
displayName: 'PBS host',
platformId: 'agent-host-1',
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: pbs.lastSeen,
agent: { agentId: id, hostname: 'pbs-main.local' },
metricsTarget: { resourceType: 'agent', resourceId: id },
}) as Resource;
lastSeen: pbs.lastSeen + 1_000,
agent: { agentId: 'agent-pbs-1', hostname: 'pbs-main.local', osName: 'Debian GNU/Linux' },
metricsTarget: { resourceType: type, resourceId: 'agent-pbs-1' },
platformData: {
sources: ['agent', 'pbs'],
agent: { agentId: 'agent-pbs-1', hostname: 'pbs-main.local' },
},
} as Resource;
render(() => (
<ProxmoxBackupServersTable
servers={[pbs, candidate('agent-pbs-a'), candidate('agent-pbs-b')]}
/>
));
expect(agent.disk).toBeUndefined();
render(() => <ProxmoxBackupServersTable servers={[pbs, agent]} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
const detail = screen.getByTestId('pbs-resource-detail');
expect(detail).toHaveAttribute('data-resource-id', 'pbs-1');
expect(detail).toHaveAttribute('data-agent-id', 'agent-pbs-1');
expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1');
expect(detail).toHaveAttribute('data-metrics-resource-type', type);
},
);
it('does not correlate a guest without host telemetry just by name', () => {
const pbs = makePbsResource();
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
const guest = {
...pbs,
id: 'vm-unrelated',
type: 'vm',
agent: undefined,
platformData: {},
pbs: undefined,
metricsTarget: { resourceType: 'vm', resourceId: 'unrelated' },
} as Resource;
render(() => <ProxmoxBackupServersTable servers={[pbs, guest]} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute(
'data-metrics-resource-id',
'pbs-main',
);
});
it.each(['agent', 'vm'] as const)(
'does not guess when an agent and %s share the PBS hostname',
(type) => {
const pbs = makePbsResource();
pbs.sources = ['pbs'];
pbs.agent = undefined;
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
const candidate = (id: string): Resource =>
({
id,
type: id === 'agent-pbs-b' ? type : 'agent',
name: 'pbs-main.local',
displayName: id,
platformId: id,
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: pbs.lastSeen,
agent: { agentId: id, hostname: 'pbs-main.local' },
metricsTarget: { resourceType: 'agent', resourceId: id },
}) as Resource;
render(() => (
<ProxmoxBackupServersTable
servers={[pbs, candidate('agent-pbs-a'), candidate('agent-pbs-b')]}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute(
'data-metrics-resource-id',
'pbs-main',
);
},
);
});
@@ -399,7 +399,7 @@ describe('ProxmoxPageSurface contract', () => {
'type=agent,vm,system-container,oci-container&source=proxmox',
'type=agent,pbs,storage,physical_disk,ceph&source=proxmox,pbs,agent',
'type=agent&source=proxmox',
'type=pbs&source=pbs',
'type=pbs,agent&source=pbs',
'type=ceph&source=proxmox',
'type=pmg&source=pmg',
]);
@@ -410,9 +410,21 @@ describe('ProxmoxPageSurface contract', () => {
expect(proxmoxPageSurfaceSource).toContain('resourceSource={storageResources}');
});
it('reuses the overview guest snapshot and fetches only PBS rows for Backups', () => {
it('reuses guests and hydrates standalone PBS telemetry without duplicate candidates', () => {
mockPathname.mockReturnValue('/proxmox/backups');
const guest = makeResource({ id: 'vm-100', type: 'vm', proxmox: { vmid: 100 } });
const guest = makeResource({
id: 'vm-100',
type: 'vm',
proxmox: { vmid: 100 },
agent: { agentId: 'guest-agent', hostname: 'pbs-vm' },
});
const agent = makeResource({
id: 'pbs-agent',
type: 'agent',
platformType: 'proxmox-pbs',
sources: ['pbs', 'agent'],
metricsTarget: { resourceType: 'agent', resourceId: 'pbs-agent' },
});
const server = makeResource({
id: 'pbs-1',
type: 'pbs',
@@ -421,8 +433,8 @@ describe('ProxmoxPageSurface contract', () => {
});
mockUseUnifiedResources.mockImplementation((options: { cacheKey: string }) => ({
resources: () => {
if (options.cacheKey === 'proxmox-overview') return [guest];
if (options.cacheKey === 'proxmox-backups-shell') return [server];
if (options.cacheKey === 'proxmox-overview') return [guest, agent];
if (options.cacheKey === 'proxmox-backups-shell') return [server, agent];
return [];
},
loading: () => false,
@@ -445,10 +457,10 @@ describe('ProxmoxPageSurface contract', () => {
]);
expect(options[3]).toMatchObject({
cacheKey: 'proxmox-backups-shell',
query: 'type=pbs&source=pbs',
query: 'type=pbs,agent&source=pbs',
});
expect(mockBackupsTableProps).toHaveBeenCalledWith(
expect.objectContaining({ workloads: [guest], servers: [server] }),
expect.objectContaining({ workloads: [guest], servers: [guest, agent, server] }),
);
});
+8 -8
View File
@@ -18,21 +18,21 @@ require (
github.com/klauspost/compress v1.19.1
github.com/moby/moby/api v1.56.0
github.com/moby/moby/client v0.6.0
github.com/oklog/ulid/v2 v2.1.1
github.com/oklog/ulid/v2 v2.1.2
github.com/opencontainers/image-spec v1.1.1
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.70.1
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
github.com/rs/zerolog v1.35.1
github.com/shirou/gopsutil/v4 v4.26.6
github.com/shirou/gopsutil/v4 v4.26.8
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/stripe/stripe-go/v82 v82.5.1
golang.org/x/crypto v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.37.0
@@ -52,7 +52,7 @@ require (
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
@@ -109,7 +109,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
+16
View File
@@ -32,6 +32,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -158,6 +160,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -199,6 +203,8 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -218,6 +224,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -250,6 +258,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
@@ -258,14 +268,20 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
@@ -8,11 +8,13 @@ package api
// keepalives while the evaluation runs; these tests pin that transport shape.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -84,19 +86,26 @@ func TestIssue1640FastEvaluationEmitsNoPadding(t *testing.T) {
}
// The transport must survive a real HTTP connection, not just a recorder: the
// status line has to be committed and flushed before the evaluation starts,
// because a proxy with a time-to-first-byte timeout shorter than the keepalive
// interval would otherwise still sever a slow readiness run.
// headers and keepalive bytes must reach the client while evaluation is still
// running, rather than remaining buffered until the final JSON response.
func TestIssue1640ResponseStartsBeforeEvaluationCompletes(t *testing.T) {
t.Parallel()
const evaluationDuration = 300 * time.Millisecond
completed := make(chan time.Time, 1)
// This is a deadlock guard, not a latency SLO. Completion is controlled
// by the client observing flushed headers and a keepalive, not by a sleep.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
releaseEvaluation := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseEvaluation) }) }
started := make(chan struct{})
completed := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
result := streamPatrolModelReadinessKeepalives(w, 50*time.Millisecond, func() ai.PatrolModelReadinessResult {
time.Sleep(evaluationDuration)
completed <- time.Now()
close(started)
<-releaseEvaluation
close(completed)
return passingReadinessResult("verified over the wire")
})
response := patrolModelReadinessSnapshot(&result, time.Now())
@@ -105,9 +114,13 @@ func TestIssue1640ResponseStartsBeforeEvaluationCompletes(t *testing.T) {
}
}))
defer server.Close()
// Release before Close even on a failed assertion, so a transport
// regression cannot strand the evaluator or hang server cleanup.
defer release()
started := time.Now()
resp, err := server.Client().Get(server.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := server.Client().Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
@@ -116,33 +129,35 @@ func TestIssue1640ResponseStartsBeforeEvaluationCompletes(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
assert.Equal(t, "no", resp.Header.Get("X-Accel-Buffering"))
assert.Less(t, time.Since(started), evaluationDuration,
"headers must reach the client before the evaluation finishes")
// Read the first body byte under a deadline well short of the evaluation.
// A buffered or unstarted response cannot satisfy it.
one := make([]byte, 1)
readDone := make(chan struct{})
var (
firstByteAt time.Time
readErr error
)
go func() {
defer close(readDone)
_, readErr = io.ReadFull(resp.Body, one)
firstByteAt = time.Now()
}()
select {
case <-readDone:
case <-time.After(evaluationDuration - 100*time.Millisecond):
t.Fatal("no body byte arrived before the evaluation completed")
case <-started:
case <-ctx.Done():
t.Fatal("evaluation did not start")
}
select {
case <-completed:
t.Fatal("evaluation completed before the client released it")
default:
}
require.NoError(t, readErr)
assert.Equal(t, "\n", string(one), "the first body byte should be a keepalive newline")
evaluationFinishedAt := <-completed
assert.True(t, firstByteAt.Before(evaluationFinishedAt),
"keepalive byte at %s must precede evaluation completion at %s", firstByteAt, evaluationFinishedAt)
// The evaluator cannot finish until this actual socket read succeeds.
// An unstarted response or buffered keepalives instead reach the request
// deadline; neither can pass just because a sleeping evaluator woke late.
one := make([]byte, 1)
_, err = io.ReadFull(resp.Body, one)
require.NoError(t, err, "keepalive must reach the client while evaluation is blocked")
assert.Equal(t, "\n", string(one), "the first body byte should be a keepalive newline")
select {
case <-completed:
t.Fatal("evaluation completed before the keepalive was observed")
default:
}
release()
select {
case <-completed:
case <-ctx.Done():
t.Fatal("evaluation did not complete after release")
}
rest, err := io.ReadAll(resp.Body)
require.NoError(t, err)
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
@@ -510,3 +511,48 @@ func TestGenerateRandomURLString_ErrorSize(t *testing.T) {
t.Fatalf("expected no error for size 0, got %v", err)
}
}
// TestOIDCRefreshDependencyContract exercises the real OAuth transport through
// Pulse's adapter, including providers that do not rotate refresh tokens.
func TestOIDCRefreshDependencyContract(t *testing.T) {
for _, rotate := range []bool{false, true} {
t.Run(fmt.Sprint("rotate=", rotate), func(t *testing.T) {
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
w.WriteHeader(400)
return
}
if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "old-refresh" {
t.Error("refresh request lost grant or token")
w.WriteHeader(400)
return
}
w.Header().Set("Content-Type", "application/json")
extra := ""
if rotate {
extra = `,"refresh_token":"rotated"`
}
fmt.Fprintf(w, `{"access_token":"access","token_type":"Bearer","expires_in":3600%s}`, extra)
}))
defer server.Close()
svc := &OIDCService{oauth2Cfg: &oauth2.Config{ClientID: "client", Endpoint: oauth2.Endpoint{TokenURL: server.URL}}, httpClient: server.Client()}
result, err := svc.RefreshToken(context.Background(), "old-refresh")
if err != nil {
t.Fatal(err)
}
want := "old-refresh"
if rotate {
want = "rotated"
}
if result.RefreshToken != want || result.AccessToken != "access" || !result.Expiry.After(time.Now()) {
t.Fatal("refresh response was not preserved")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := svc.RefreshToken(ctx, "old-refresh"); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled refresh error = %v", err)
}
})
}
}
@@ -0,0 +1,69 @@
//go:build linux
package hostmetrics
import (
"context"
"math"
"testing"
"time"
gocpu "github.com/shirou/gopsutil/v4/cpu"
gomem "github.com/shirou/gopsutil/v4/mem"
gonet "github.com/shirou/gopsutil/v4/net"
)
// Exercise the actual dependency's Linux parsers, not the collector's mocked
// wrappers. Do not enumerate usage on mounts: remote filesystems may block.
func TestNativeSamplingLinux(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
first, err := gocpu.TimesWithContext(ctx, false)
if err != nil || len(first) != 1 {
t.Fatalf("CPU sample unavailable: count=%d err=%v", len(first), err)
}
second, err := gocpu.TimesWithContext(ctx, false)
if err != nil || len(second) != 1 {
t.Fatalf("second CPU sample unavailable: count=%d err=%v", len(second), err)
}
total, busy := cpuBusyTotal(first[0])
later, _ := cpuBusyTotal(second[0])
if math.IsNaN(total) || math.IsInf(total, 0) || total <= 0 || busy < 0 || busy > total || later < total {
t.Fatal("invalid cumulative CPU accounting")
}
memory, err := gomem.VirtualMemoryWithContext(ctx)
if err != nil {
t.Fatalf("memory sample: %v", err)
}
if memory.Total == 0 || memory.Available > memory.Total || memory.Used > memory.Total {
t.Fatal("invalid memory accounting")
}
interfaces, err := gonet.InterfacesWithContext(ctx)
if err != nil || len(interfaces) == 0 {
t.Fatalf("interface sample unavailable: count=%d err=%v", len(interfaces), err)
}
counters, err := gonet.IOCountersWithContext(ctx, true)
if err != nil || len(counters) == 0 {
t.Fatalf("network counters unavailable: count=%d err=%v", len(counters), err)
}
names := make(map[string]bool)
for _, iface := range interfaces {
names[iface.Name] = true
}
matched := false
for _, counter := range counters {
matched = matched || names[counter.Name]
}
if !matched {
t.Fatal("network counters have no enumerated interface")
}
partitions, err := partitionsVisibleToAgent(ctx, true)
if err != nil || len(partitions) == 0 {
t.Fatalf("mount parser unavailable: count=%d err=%v", len(partitions), err)
}
for _, partition := range partitions {
if partition.Mountpoint == "" || partition.Fstype == "" {
t.Fatal("mount parser lost mountpoint or filesystem")
}
}
}
+23
View File
@@ -7,6 +7,9 @@ import (
"path/filepath"
"testing"
"time"
"github.com/oklog/ulid/v2"
"github.com/stretchr/testify/require"
)
func TestNewUpdateHistory(t *testing.T) {
@@ -865,3 +868,23 @@ func contains(s, substr string) bool {
}
return false
}
// TestUpdateHistoryIDDependencyContract protects persisted ID encoding and
// uniqueness rather than only checking that the generated string is nonempty.
func TestUpdateHistoryIDDependencyContract(t *testing.T) {
h, err := NewUpdateHistory(t.TempDir())
require.NoError(t, err)
seen := make(map[string]bool)
for range 64 {
id, err := h.CreateEntry(context.Background(), UpdateHistoryEntry{Action: "update", Status: StatusInProgress})
require.NoError(t, err)
parsed, err := ulid.ParseStrict(id)
require.NoError(t, err)
require.Equal(t, id, parsed.String())
require.False(t, seen[id], "duplicate history ID")
seen[id] = true
entry, err := h.GetEntry(id)
require.NoError(t, err)
require.Equal(t, id, entry.EventID)
}
}
@@ -157,7 +157,7 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
`github.event_name != 'workflow_run' || !contains(github.event.workflow_run.display_title, '-')`,
"release-continuity:",
"Latest stable release continuity",
"docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5",
"docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e",
`"repos/${REPOSITORY}/releases/latest"`,
`scripts/release_control/release_continuity.py release`,
"release-diagnostic.json",
@@ -1747,7 +1747,7 @@ func TestReleaseCandidateRequiresPlatformNativeAgentSigning(t *testing.T) {
`sign-windows-agent:`,
`collect-windows-signing:`,
`windows_signing_backend:`,
`signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2`,
`signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2`,
`github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}`,
`wait-for-completion: false`,
`windows-signing-request.json`,
@@ -1852,7 +1852,7 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) {
`does not trust the configured release signing key.`,
`id-token: write`,
`attestations: write`,
`uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4`,
`uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2`,
}
containerJob := workflowJobBlock(t, string(qualifierWorkflowBytes), "qualify")
for _, needle := range []string{
@@ -2272,7 +2272,7 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
workflow := string(workflowBytes) + "\n" + string(profileBytes)
required := []string{
`- name: Tailscale`,
`uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4`,
`uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3`,
`oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}`,
`oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}`,
`tags: tag:infra`,
@@ -3189,7 +3189,7 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) {
`name: Verify public GHCR chart identity and provenance`,
`helm registry logout ghcr.io || true`,
`name: Authenticate OCI attestation client with GHCR`,
`uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0`,
`uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0`,
`registry: ghcr.io`,
`username: ${{ github.actor }}`,
`password: ${{ github.token }}`,
@@ -0,0 +1,225 @@
{
"actions/attest": {
"sha": "1e69f48acb82d1966a394da916b4c1698aa569d6",
"manifest_sha256": "9e4a1b808433f9ec87120b534e11fc35469a039bdbdc62b019441444c9ad0449",
"inputs": [
"create-storage-record",
"github-token",
"predicate",
"predicate-path",
"predicate-type",
"push-to-registry",
"sbom-path",
"show-summary",
"subject-checksums",
"subject-digest",
"subject-name",
"subject-path",
"subject-version"
],
"outputs": [
"attestation-id",
"attestation-url",
"bundle-path",
"storage-record-ids"
],
"runs": {
"using": "node24",
"main": "./dist/index.js"
}
},
"azure/setup-helm": {
"sha": "9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310",
"manifest_sha256": "a80a29bbe87c77663a47a75d4163cf54f0bd24ae2485b868dd81d6758ec29647",
"inputs": [
"downloadBaseURL",
"token",
"version"
],
"outputs": [
"helm-path"
],
"runs": {
"using": "node24",
"main": "lib/index.js"
}
},
"docker/build-push-action": {
"sha": "53b7df96c91f9c12dcc8a07bcb9ccacbed38856a",
"manifest_sha256": "5c1ade75252eabe541b96cc39a2165a07c8850f9bcd3f9b75f5394fbdfb215fd",
"inputs": [
"add-hosts",
"allow",
"annotations",
"attests",
"build-args",
"build-contexts",
"builder",
"cache-from",
"cache-to",
"call",
"cgroup-parent",
"context",
"file",
"github-token",
"labels",
"load",
"network",
"no-cache",
"no-cache-filters",
"outputs",
"platforms",
"provenance",
"pull",
"push",
"sbom",
"secret-envs",
"secret-files",
"secrets",
"shm-size",
"ssh",
"tags",
"target",
"ulimit"
],
"outputs": [
"digest",
"imageid",
"metadata"
],
"runs": {
"using": "node24",
"main": "dist/index.cjs",
"post": "dist/index.cjs"
}
},
"docker/login-action": {
"sha": "dbcb813823bdd20940b903addbd779551569679f",
"manifest_sha256": "e65e6bdc57fc10adcee036a393229aae6b9a9f451b69528f4ff50d4b0e93f156",
"inputs": [
"ecr",
"logout",
"password",
"registry",
"registry-auth",
"scope",
"username"
],
"outputs": [],
"runs": {
"using": "node24",
"main": "dist/index.cjs",
"post": "dist/index.cjs"
}
},
"docker/setup-buildx-action": {
"sha": "37fe631027851001ddb9b187196cc803df7f5f0e",
"manifest_sha256": "9c55229011a0566758962f36c74309c8ab06f8905a5997591a6a46319401ab38",
"inputs": [
"append",
"buildkitd-config",
"buildkitd-config-inline",
"buildkitd-flags",
"cache-binary",
"cleanup",
"driver",
"driver-opts",
"endpoint",
"keep-state",
"name",
"platforms",
"use",
"version"
],
"outputs": [
"driver",
"endpoint",
"flags",
"name",
"nodes",
"platforms",
"status"
],
"runs": {
"using": "node24",
"main": "dist/index.cjs",
"post": "dist/index.cjs"
}
},
"docker/setup-qemu-action": {
"sha": "1f40c72289eff860ee54a304f1438e3cff362e0a",
"manifest_sha256": "9c74c218c37b889976b0af59861f3fceaec87bd12da00bf6dc37ded77d8954f8",
"inputs": [
"cache-image",
"image",
"platforms",
"reset"
],
"outputs": [
"platforms"
],
"runs": {
"using": "node24",
"main": "dist/index.cjs",
"post": "dist/index.cjs"
}
},
"signpath/github-action-submit-signing-request": {
"sha": "c92b958760219087e01f8d67a1669ed57afe2627",
"manifest_sha256": "d0b52fdfa234d87c1ff4d21c06a91d2b6e0dd45e1a8866ad3b58cbdeb9aef326",
"inputs": [
"api-token",
"artifact-configuration-slug",
"connector-url",
"download-signed-artifact-timeout-in-seconds",
"github-artifact-id",
"github-token",
"organization-id",
"output-artifact-directory",
"parameters",
"project-slug",
"service-unavailable-timeout-in-seconds",
"signing-policy-slug",
"skip-decompress",
"wait-for-completion",
"wait-for-completion-timeout-in-seconds"
],
"outputs": [
"signed-artifact-download-url",
"signing-request-id",
"signing-request-web-url",
"signpath-api-url"
],
"runs": {
"using": "node24",
"main": "index.js"
}
},
"tailscale/github-action": {
"sha": "780049a30b6ff5c378a9e7b389d15ece7a204888",
"manifest_sha256": "d63e612f47dad1520586efec4b79f2c7f0e0aadabeeca53709aa616202e43c6c",
"inputs": [
"args",
"audience",
"authkey",
"hostname",
"oauth-client-id",
"oauth-secret",
"ping",
"retry",
"sha256sum",
"statedir",
"tags",
"tailscaled-args",
"timeout",
"use-cache",
"version"
],
"outputs": [],
"runs": {
"using": "node24",
"main": "dist/index.js",
"post": "dist/logout/index.js"
}
}
}
@@ -10,6 +10,37 @@ from release_promotion_policy_support import (
class ReleasePromotionPolicySupportTest(unittest.TestCase):
def test_action_inputs_do_not_satisfy_dispatch_requirements(self) -> None:
# Signing action inputs are nested consumer data, not callable workflow
# inputs. A pin upgrade must not make this dispatch guard accept them.
content = """
on:
workflow_dispatch:
inputs:
version:
type: string
jobs:
sign:
runs-on: ubuntu-24.04
steps:
- uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627
with:
organization-id: fixture
api-token: fixture
expected_head_sha: fixture
"""
self.assertEqual(parse_workflow_dispatch_inputs(content), ("version",))
with patch("release_promotion_policy_support.branch_workflow_text", return_value=content), patch(
"release_promotion_policy_support.origin_default_branch", return_value="main"
):
self.assertEqual(
missing_workflow_dispatch_inputs(
workflow_path=".github/workflows/signpath-test-signing.yml",
required_inputs=("version", "expected_head_sha"),
),
("main", ("expected_head_sha",)),
)
def test_parse_workflow_dispatch_inputs_reads_top_level_inputs(self) -> None:
content = """
name: Example
@@ -302,6 +302,40 @@ STAGED_GOVERNANCE_INPUT_ERRORS = (
class ReleasePromotionPolicyTest(unittest.TestCase):
def test_reviewed_action_manifests_cover_all_release_consumers(self) -> None:
# Snapshot is derived from each immutable upstream action.yml, not from
# our consumers: unknown inputs therefore fail rather than being blessed.
manifests = read_json("scripts/release_control/action_consumer_manifests.json")
seen = set()
for path in (REPO_ROOT / ".github/workflows").glob("*.yml"):
workflow = yaml.safe_load(read(str(path.relative_to(REPO_ROOT))))
for job in workflow.get("jobs", {}).values():
for step in job.get("steps", []):
action, _, revision = step.get("uses", "").partition("@")
if action not in manifests:
continue
with self.subTest(workflow=path.name, action=action):
manifest = manifests[action]
self.assertEqual(revision, manifest["sha"])
self.assertEqual(manifest["runs"]["using"], "node24")
self.assertRegex(manifest["manifest_sha256"], r"^[0-9a-f]{64}$")
self.assertLessEqual(set(step.get("with", {})), set(manifest["inputs"]))
seen.add(action)
if action == "azure/setup-helm":
self.assertEqual(str(step["with"]["version"]).lstrip("v"), "3.15.2")
if action == "actions/attest":
if path.name in {"publish-docker.yml", "publish-helm-chart.yml"}:
self.assertIn("subject-name", step["with"])
self.assertIn("subject-digest", step["with"])
self.assertTrue(step["with"]["push-to-registry"])
else:
self.assertIn("subject-checksums", step["with"])
if action == "signpath/github-action-submit-signing-request":
self.assertIn("api-token", step["with"])
self.assertIn("organization-id", step["with"])
self.assertEqual(seen, set(manifests))
def setUp(self) -> None:
if USE_STAGED_GOVERNANCE and not REQUIRES_STAGED_GOVERNANCE_INPUTS:
self.skipTest("staged slice does not touch the promotion-proof surface")
@@ -1546,7 +1580,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
workflow,
)
self.assertIn(
"signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2",
"signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2",
workflow,
)
self.assertIn("signedArtifactsPublished = $false", workflow)
@@ -1924,7 +1958,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("windows_signing_backend: signpath", content)
self.assertIn('if [[ "$REQUIRE_WINDOWS_SIGNING" == "true" ]]', candidate_workflow)
self.assertIn("inputs.require_windows_signing", candidate_workflow)
self.assertIn("signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2", candidate_workflow)
self.assertIn("signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2", candidate_workflow)
self.assertIn("github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}", candidate_workflow)
self.assertIn("windows-signing-evidence.json", candidate_workflow)
for signpath_setting in (
@@ -1959,7 +1993,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("bash .github/scripts/setup-demo-ssh.sh", update_demo_workflow)
self.assertIn("bash .github/scripts/check-demo-reachability.sh", update_demo_workflow)
self.assertIn("ping: ${{ secrets.DEMO_SERVER_HOST }}", update_demo_workflow)
self.assertIn("tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4", update_demo_workflow)
self.assertIn("tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3", update_demo_workflow)
self.assertIn("uses: ./.github/workflows/update-demo-server.yml", deploy_demo_workflow)
self.assertIn("verify_only: true", deploy_demo_workflow)
self.assertIn('MAX_SSH_SETUP_ATTEMPTS="${DEMO_SSH_SETUP_ATTEMPTS:-3}"', demo_ssh_helper)
@@ -2003,7 +2037,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("id-token: write", candidate_workflow)
self.assertIn("attestations: write", candidate_workflow)
self.assertIn(
"uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4",
"uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2",
candidate_workflow,
)
self.assertIn(
@@ -2318,7 +2352,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("fail-fast: false", publish)
self.assertIn("if: matrix.image == 'server'", publish)
self.assertIn("if: matrix.image == 'control-plane'", publish)
self.assertIn("uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", publish)
self.assertIn("uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2", publish)
self.assertIn("subject-name: docker.io/rcourtman/pulse", publish)
self.assertIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse", publish)
# pulse-agent ships as release-asset binaries, not as a Docker image
@@ -2362,7 +2396,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("tag: latest", dry_run_workflow)
self.assertIn("verify_only: true", dry_run_workflow)
self.assertIn("Verify Current Stable Demo Path (No Mutation)", dry_run_workflow)
self.assertIn("tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4", demo)
self.assertIn("tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3", demo)
self.assertIn("oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}", demo)
self.assertIn("oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}", demo)
self.assertIn("ping: ${{ secrets.DEMO_SERVER_HOST }}", demo)
@@ -0,0 +1,136 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test as base } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`pbs-history-targets-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: 'worker' },
],
});
test.use({ serviceWorkers: 'block' });
for (const width of [1280, 390]) {
test(`PBS History uses merged and standalone telemetry at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.setViewportSize({ width, height: 844 });
await page.addInitScript(() => {
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readonly url: string;
readyState = FakeWebSocket.CLOSED;
onopen: ((event: Event) => void) | null = null;
onclose:
| ((event: {
code?: number;
reason?: string;
wasClean?: boolean;
}) => void)
| null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(url: string) {
this.url = url;
queueMicrotask(() => {
this.onclose?.({
code: 1006,
reason: 'e2e websocket disabled',
wasClean: false,
});
});
}
close() {
this.readyState = FakeWebSocket.CLOSED;
}
send() {}
addEventListener() {}
removeEventListener() {}
}
// @ts-expect-error Playwright init script runs in the browser context.
window.WebSocket = FakeWebSocket;
});
const common = { status: 'online', lastSeen: Date.now(), sourceType: 'hybrid',
cpu: { current: 10 }, memory: { current: 20, total: 1000, used: 200, free: 800 } };
const servers = ['vm', 'agent'].map(type => ({ ...common,
id: `pbs-${type}`, type: 'pbs', name: `backup-${type}`, displayName: `backup-${type}`,
platformId: `backup-${type}`, platformType: 'proxmox-pbs', sources: ['pbs'],
metricsTarget: { resourceType: 'agent', resourceId: `wrong-${type}` },
pbs: { instanceId: `backup-${type}`, hostname: `backup-${type}`, datastores: [] },
}));
const hosts = ['vm', 'agent'].map(type => ({ ...common, id: `host-${type}`, type,
name: `backup-${type}`, displayName: `backup-${type}`, platformId: `host-${type}`,
platformType: type === 'vm' ? 'proxmox-pve' : 'proxmox-pbs',
sources: type === 'vm' ? ['proxmox', 'agent'] : ['pbs', 'agent'],
agent: { agentId: `host-${type}`, hostname: `backup-${type}`, osName: 'Debian' },
metricsTarget: { resourceType: type, resourceId: `history-${type}` },
}));
await page.route('**/api/resources?**', async route => {
const query = new URL(route.request().url()).searchParams;
const types = (query.get('type') || '').split(',');
const source = (query.get('source') || '').split(',');
const data = [...servers, ...hosts].filter(r => types.includes(r.type) && r.sources.some(s => source.includes(s)));
await route.fulfill({ json: { data, total: data.length } });
});
const targets: string[] = [];
await page.route('**/api/metrics-store/history?**', async route => {
const q = new URL(route.request().url()).searchParams;
targets.push(`${q.get('resourceType')}/${q.get('resourceId')}`);
await route.fulfill({ json: { metrics: { cpu: [0, 1, 2].map(i => ({ timestamp: Date.now() - (2-i)*60000, value: 10+i, min: 10+i, max: 10+i, count: 1 })), memory: [0, 1, 2].map(i => ({ timestamp: Date.now() - (2-i)*60000, value: 20+i, min: 20+i, max: 20+i, count: 1 })) }, resourceId: q.get('resourceId'), resourceType: q.get('resourceType') } });
});
await page.goto('/proxmox/backups', { waitUntil: 'domcontentloaded' });
for (const type of ['vm', 'agent']) {
const expand = page.getByRole('button', { name: `Expand details for backup-${type}`, exact: true });
await expand.focus();
await page.keyboard.press('Enter');
await page.getByRole('tab', { name: 'History', exact: true }).click();
await expect.poll(() => targets).toContain(`${type}/history-${type}`);
expect(targets.some(t => t.includes('wrong-'))).toBe(false);
const utilization = page.getByTestId('guest-history-group-chart').first();
await expect(utilization.getByText('Collecting history')).toHaveCount(0);
await expect(utilization.locator('svg path')).toHaveCount(2);
await page.screenshot({ path: testInfo.outputPath(`pbs-${type}-${width}.png`), fullPage: true });
await page.getByRole('button', { name: `Collapse details for backup-${type}`, exact: true }).focus();
await page.keyboard.press('Enter');
}
});
}
@@ -459,6 +459,187 @@ test.describe("Infrastructure onboarding", () => {
).toHaveValue("https://discovered-pve.lab:8006");
});
for (const width of [1280, 390]) {
test(`import preview ignores obsolete responses at ${width}px`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 844 });
await prepareOnboardingPage(page);
// The "Run discovery" scan button only renders when discovery is enabled.
// Enable it by passing the real system-settings response through with
// discoveryEnabled flipped on, so this test can exercise the scan flow.
await page.route("**/api/system/settings", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
const response = await route.fetch();
const body = await response.json();
body.discoveryEnabled = true;
await route.fulfill({ response, json: body });
});
await page.route("**/api/discover", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname !== "/api/discover") {
await route.continue();
return;
}
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
servers: [],
errors: [],
cached: true,
updated: 0,
age: 0,
}),
});
return;
}
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
servers: [
{
ip: "10.0.0.55",
port: 8006,
type: "pve",
version: "8.2.2",
hostname: "discovered-pve.lab",
},
],
errors: [],
cached: false,
scanning: false,
timestamp: 1_700_000_000_000,
}),
});
return;
}
await route.continue();
});
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await page.getByRole("button", { name: /Run discovery/i }).click();
await expect(
page.getByText("discovered-pve.lab", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^Review$/i }),
).toBeVisible();
await page.getByRole("button", { name: /^Review$/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pve$/, {
timeout: 15_000,
});
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page
.getByRole("dialog")
.getByRole("heading", { name: "Add Proxmox VE", exact: true }),
).toBeVisible();
await expect(
page.getByPlaceholder("https://proxmox.example.com:8006"),
).toHaveValue("https://discovered-pve.lab:8006");
const dialog = page.getByRole("dialog");
const endpoint = dialog.getByLabel(/^Endpoint URL/);
const previewButton = dialog.getByRole("button", {
name: "Preview impact",
exact: true,
});
const requests: import("@playwright/test").Route[] = [];
await page.route(
"**/api/license/monitored-system-ledger/preview",
(route) => {
requests.push(route);
},
);
const success = {
current_count: 1,
projected_count: 1,
additional_count: 0,
effect: "unchanged",
current_systems: [],
projected_systems: [],
current_system: null,
projected_system: null,
};
await previewButton.click();
await expect.poll(() => requests.length).toBe(1);
await dialog.getByLabel(/Approve this import plan/i).check();
await endpoint.fill("https://replacement.lab:8006");
await expect(
dialog.getByLabel(/Approve this import plan/i),
).not.toBeChecked();
await expect(previewButton).toBeEnabled();
await previewButton.click();
await expect.poll(() => requests.length).toBe(2);
await requests[0].fulfill({
status: 400,
json: { error: "Obsolete endpoint failure" },
});
await expect(
dialog.getByText("Calculating monitored-system impact…"),
).toBeVisible();
await expect(dialog.getByText("Obsolete endpoint failure")).toHaveCount(
0,
);
await requests[1].fulfill({ json: success });
await expect(
dialog.getByText("This change keeps monitored-system count unchanged"),
).toBeVisible();
// An obsolete success must not reappear even after returning to the same endpoint.
await previewButton.click();
await expect.poll(() => requests.length).toBe(3);
await endpoint.fill("https://third.lab:8006");
await endpoint.fill("https://replacement.lab:8006");
await requests[2].fulfill({ json: success });
await expect(
dialog.getByText("This change keeps monitored-system count unchanged"),
).toHaveCount(0);
await previewButton.click();
await expect.poll(() => requests.length).toBe(4);
await requests[3].fulfill({
status: 400,
json: { error: "Current endpoint failure" },
});
await expect(dialog.getByText("Current endpoint failure")).toBeVisible();
await expect(previewButton).toBeEnabled();
await page.screenshot({
path: testInfo.outputPath(`preview-${width}.png`),
fullPage: true,
});
await previewButton.click();
await expect.poll(() => requests.length).toBe(5);
await dialog.getByRole("button", { name: /^(Cancel|Close)$/ }).click();
await expect(dialog).not.toBeVisible();
await requests[4].fulfill({ json: success });
await expect(
page.getByText("This change keeps monitored-system count unchanged"),
).toHaveCount(0);
});
}
test("desktop detect utility offers no-match agent fallback from the add-infrastructure picker", async ({
page,
}, testInfo) => {