mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #2019 from rcourtman/maintainer/20260909T233300Z
Preserve import previews and PBS history while updating dependencies
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+42
-1
@@ -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'}>
|
||||
|
||||
+87
-58
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+19
-7
@@ -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] }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user