diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a2cabfa2e..e84d938eb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,7 +23,7 @@ permissions: # E2E workflow's concurrency policy. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: secret-scan: diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index d2c7a1234..ce9f3a5a0 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -31,7 +31,7 @@ on: # collapse to the newest pending one, so intermediate pushes skip. concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 458c6fd77..49af7a71d 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10042,7 +10042,21 @@ ] } ], - "work_claims": [], + "work_claims": [ + { + "id": "pulse-maintainer-lane-l8", + "agent_id": "pulse-maintainer", + "summary": "Verify and backport mount-list overflow repair for issue2051", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-09-14T23:28:45Z", + "heartbeat_at": "2026-09-14T23:28:45Z", + "expires_at": "2026-09-15T01:28:45Z", + "work_item": { + "kind": "lane", + "id": "L8" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a46ed82fc..3cb87b1ba 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,15 @@ ## Purpose +### Physical disk skipped-poll identity + +Read-state round trips preserve the provider SourceID rather than rehashing a +canonical ID. Older views without source metadata retain their ID fallback. +Preserve disk metadata supported by this release model. Repeated-cycle +regressions cover serial-less disks, node/controller separation, JSON identity, +metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076 +without main-only interval fields or unrelated main metrics changes. + ### Host-local network evidence exclusion Automatic PVE association must not treat loopback, unspecified, multicast or diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e0167c77e..98a87fe75 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,13 @@ ## Purpose +### Superseded pull-request validation + +Build and Test and Core E2E cancel prior validation only for pull_request events. +Branch pushes and manual runs retain their verdicts. This backports reviewed +main092e98823903aa90149268d8f851aad46ea72060; triggers, jobs, permissions and +concurrency group identities are unchanged, verified by semantic YAML comparison. + ### Immutable release source The committed release-note visual plan must pass the same validator as the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3eb16b639..332500852 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,8 @@ ## Purpose +Mount lists stay in the parent scroll flow so overlay scrollbars cannot hide additional mounts. The maintenance backport preserves the reviewed DisksCard layout repair; two and twenty-four mount cases retain keyboard access to the final mount. + Overview delivery diagnoses use latest-started refresh ownership. Older bulk responses cannot overwrite newer card notification status, and an empty active alert set invalidates outstanding reads. Disposal also prevents updates. Failed diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 5f8d7b409..9bb048c3d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,15 @@ ## Purpose +### Physical disk skipped-poll identity + +Read-state round trips preserve the provider SourceID rather than rehashing a +canonical ID. Older views without source metadata retain their ID fallback. +Preserve disk metadata supported by this release model. Repeated-cycle +regressions cover serial-less disks, node/controller separation, JSON identity, +metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076 +without main-only interval fields or unrelated main metrics changes. + **Availability backfill preserves concurrent discovery changes (7 September 2026)** The backfill List snapshot is a work list, not an authoritative record to save. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 1b1163b00..8099b7d21 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1612,6 +1612,7 @@ "internal/monitoring/availability_probe_agent_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_ps1_test.go", "scripts/installtests/install_sh_test.go" @@ -6333,6 +6334,7 @@ "internal/monitoring/monitor_mock_alerts_test.go", "internal/monitoring/monitor_pve_cluster_refresh_test.go", "internal/monitoring/monitor_pve_guest_lxc_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "internal/monitoring/proxmox_large_cluster_poll_budget_test.go", "internal/monitoring/pve_protection_observation_test.go", "internal/monitoring/ratetracker_test.go", @@ -8046,6 +8048,7 @@ "exact_files": [ "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "internal/monitoring/issue1595_collection_trust_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "internal/unifiedresources/adapter_coverage_test.go", "internal/unifiedresources/adapters_test.go", "internal/unifiedresources/ceph_pool_health_contract_test.go", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2f02f8eb0..38a08cacb 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -15,6 +15,15 @@ ## Purpose +### Physical disk skipped-poll identity + +Read-state round trips preserve the provider SourceID rather than rehashing a +canonical ID. Older views without source metadata retain their ID fallback. +Preserve disk metadata supported by this release model. Repeated-cycle +regressions cover serial-less disks, node/controller separation, JSON identity, +metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076 +without main-only interval fields or unrelated main metrics changes. + TrueNAS EMERGENCY evidence retains canonical critical severity and the existing resource and incident identity. Repeated EMERGENCY observations remain actionable through the alerts consumer and interrupt pending recovery; confirmed absence, not an unmapped severity, supplies recovery evidence. Projection, dispatch and recovery-streak regression tests exercise this boundary. ResourceIncident carries optional nativeSeverity JSON evidence independently of canonical Severity and identity. Missing nativeSeverity remains compatible with older payloads. TrueNAS INFO and NOTICE may share canonical monitor risk without becoming indistinguishable to alert consumers; native severity does not change resource or incident identity. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 96707ca8f..b17757d63 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,44 +1,33 @@ { "version": 1, - "base_sha": "062e7c805046f8a40852ac0fd92376fa6096950d", - "verified_at": "2026-09-14T22:42:59.987155Z", + "base_sha": "ea4c4a71475629e45dd053dc024532443c293809", + "verified_at": "2026-09-14T23:30:14.962745Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/notifications.ts", - "frontend-modern/src/components/Alerts/WebhookConfigList.tsx", - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts" + "frontend-modern/src/components/shared/cards/DisksCard.tsx" ], "content_sha256": { - "frontend-modern/src/api/notifications.ts": "ed4d7dd4946ed2295dff919134f394d0139d270bb446d069f4ddc8a9784b1667", - "frontend-modern/src/components/Alerts/WebhookConfigList.tsx": "0830ab5c9f7e0173dbdf3e92ed0e0c9cbf0a34942edb58001a3220d43b39d6c3", - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "49c4aadf57899de6cc47690ecc682787d82183d76bacc8c6e250d5da39e30033" + "frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028" }, "routes": [ - "/browser-tests/severity.html" + "/qualification/disks/" ], "viewports": [ { - "width": 1440, - "height": 1000 + "width": 600, + "height": 500 }, { - "width": 390, - "height": 844 + "width": 1200, + "height": 500 } ], "states": [ - "Existing warning email and webhook", - "All, critical and warning severity saved and reloaded", - "Cancelled webhook edit", - "New warning webhook persisted" + "2 and 24 mounts, light and dark themes, in parent scroll flow" ], "interactions": [ - "Change production email severity selector, save through NotificationsAPI to synthetic backend, reload page and assert each supported value", - "Edit production webhook, save and reload each supported severity", - "Cancel changed webhook severity and verify original warning preserved", - "Create warning webhook and reload synthetic persisted store", - "Inspect desktop edit and narrow saved screenshots" + "Focus Before disks, End then Tab; final mount visible and After disks focused" ], - "command": "pulse-worker-browser severity-browser.cjs", - "notes": "Production-component fixture built with Vite esnext and project CSS. Synthetic email HTTP responses and webhook localStorage persistence; no live backend, notification delivery or whole-application navigation proof. Runtime source hashes match the verified implementation. Desktop edit and narrow saved screenshots inspected." + "command": "pulse-worker-browser mount-proof.cjs", + "notes": "Production DisksCard fixture with project CSS; eight styled Chromium cases verified at the original recorded observation time. Source content unchanged by recovery. Earlier warning-severity evidence belongs to already published PR2090 and is retained there, not claimed as a new observation here. Screenshots 600px dark with 24 mounts and 1200px light with 2 mounts inspected. Firefox and reporter attachments not verified. This is synthetic component proof, not installed reporter acceptance. Initial module-path and unstyled runs not counted." } diff --git a/frontend-modern/qualification/disks/index.html b/frontend-modern/qualification/disks/index.html new file mode 100644 index 000000000..9de75126e --- /dev/null +++ b/frontend-modern/qualification/disks/index.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/frontend-modern/qualification/disks/main.tsx b/frontend-modern/qualification/disks/main.tsx new file mode 100644 index 000000000..974b20f12 --- /dev/null +++ b/frontend-modern/qualification/disks/main.tsx @@ -0,0 +1,22 @@ +import { render } from 'solid-js/web'; +import { DisksCard } from '../../src/components/shared/cards/DisksCard'; +import '../../src/index.css'; +const params = new URLSearchParams(location.search); +document.documentElement.classList.toggle('dark', params.get('theme') === 'dark'); +const disks = Array.from({ length: Number(params.get('count') ?? 24) }, (_, i) => ({ + mountpoint: `/mnt/disk-${i}`, + total: 1000000000, + used: 500000000, + free: 500000000, + usage: 0.5, +})); +render( + () => ( +
+ + + +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 8296f22fe..055920188 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -344,6 +344,14 @@ const frontendIndexCssSource = readFileSync(join(process.cwd(), 'src/index.css') const readFrontendSource = (path: string) => readFileSync(join(process.cwd(), path), 'utf8'); describe('shared primitive guardrails', () => { + it('keeps disk mounts in the parent scrolling flow rather than a hidden nested list', () => { + const source = readFrontendSource('src/components/shared/cards/DisksCard.tsx'); + expect(source).toContain('data-testid="disks-card-mounts"'); + expect(source).not.toMatch(/max-h-|overflow-y-|custom-scrollbar/); + expect(source).toContain(''); + expect(source).toContain(' { expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([ 'utilization', diff --git a/frontend-modern/src/components/shared/cards/DisksCard.tsx b/frontend-modern/src/components/shared/cards/DisksCard.tsx index cffd2d9b2..0644b0772 100644 --- a/frontend-modern/src/components/shared/cards/DisksCard.tsx +++ b/frontend-modern/src/components/shared/cards/DisksCard.tsx @@ -47,7 +47,8 @@ export const DisksCard: Component = (props) => { )} -
+ {/* Keep mounts in the parent scroll flow: overlay scrollbars can hide a nested list. */} +
{(disk) => { const total = disk.total ?? 0; diff --git a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx index acd11805b..af0170e5e 100644 --- a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx +++ b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx @@ -55,6 +55,21 @@ describe('DisksCard', () => { expect(screen.getByText('/data')).toBeInTheDocument(); }); + it.each([2, 24])('keeps all %i mounts in the parent scroll flow', (count) => { + const disks = Array.from({ length: count }, (_, i) => ({ + mountpoint: `/mnt/disk-${i}`, + total: 100, + used: 50, + free: 50, + usage: 0.5, + })); + render(() => ); + const mounts = screen.getByTestId('disks-card-mounts'); + expect(mounts.children).toHaveLength(count); + expect(mounts.className).not.toMatch(/max-h-|overflow-|custom-scrollbar/); + expect(screen.getByTitle(`/mnt/disk-${count - 1}`)).toBeInTheDocument(); + }); + it('renders nothing when no disks are available', () => { const { container } = render(() => ); diff --git a/internal/api/resourceapi/resources_registry_propagation_test.go b/internal/api/resourceapi/resources_registry_propagation_test.go new file mode 100644 index 000000000..d5bb70d66 --- /dev/null +++ b/internal/api/resourceapi/resources_registry_propagation_test.go @@ -0,0 +1,66 @@ +package resourceapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// This is the final leg of collector -> ApplyDockerReport -> /api/resources. +func TestResourcesRegistryResponseQualification(t *testing.T) { + path := os.Getenv("PULSE_REGISTRY_SNAPSHOT_PROOF") + if path == "" { + t.Skip("set PULSE_REGISTRY_SNAPSHOT_PROOF to ingestion qualification output") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var snapshots []models.StateSnapshot + if err := json.Unmarshal(data, &snapshots); err != nil { + t.Fatal(err) + } + if len(snapshots) != 2 { + t.Fatal("expected failure and recovery snapshots") + } + for phase, snapshot := range snapshots { + registry := unified.NewRegistry(nil) + registry.IngestSnapshot(snapshot) + h := NewQueryService(&config.Config{DataPath: t.TempDir()}) + h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: snapshot, resources: registry.ListByType(unified.ResourceTypeAppContainer)}) + rec := httptest.NewRecorder() + h.HandleListResources(rec, httptest.NewRequest(http.MethodGet, "/api/resources?type=app-container&limit=100", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String()) + } + var response ResourcesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Data) != 4 { + t.Fatalf("expected four resources: %s", rec.Body.String()) + } + for _, r := range response.Data { + var want *models.DockerContainerUpdateStatus + for _, c := range snapshot.DockerHosts[0].Containers { + if c.Name == r.Name { + want = c.UpdateStatus + } + } + if want == nil || r.Docker == nil || r.Docker.UpdateStatus == nil { + t.Fatalf("lost identity/status: %+v", r) + } + got := r.Docker.UpdateStatus + if got.CurrentDigest != want.CurrentDigest || got.LatestDigest != want.LatestDigest || got.Error != want.Error || got.UpdateAvailable != want.UpdateAvailable { + t.Fatalf("projection changed status: %+v vs %+v", got, want) + } + } + t.Logf("phase %d endpoint=%s", phase, rec.Body.String()) + } +} diff --git a/internal/dockeragent/collect_registry_response_test.go b/internal/dockeragent/collect_registry_response_test.go new file mode 100644 index 000000000..7c8da25ad --- /dev/null +++ b/internal/dockeragent/collect_registry_response_test.go @@ -0,0 +1,114 @@ +package dockeragent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + containertypes "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + "github.com/rs/zerolog" +) + +// Optional output feeds the ingest and API qualification probes without +// substituting hand-written update statuses for collector output. +func TestCollectRegistryResponseIsolation(t *testing.T) { + refs := []string{"redis:8-alpine", "postgres:16-alpine", "ghcr.io/searxng/searxng:latest", "ghcr.io/paperless-ngx/paperless-ngx:latest"} + var reports []agentsdocker.Report + for _, phase := range []string{"invalid", "valid"} { + checker := NewRegistryChecker(zerolog.Nop()) + calls := map[string]int{} + checker.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.docker.io" || strings.HasSuffix(req.URL.Path, "/token") { + return newStringResponse(200, nil, `{"token":"synthetic"}`), nil + } + key := req.URL.Host + req.URL.Path + calls[key]++ + var i int + for i = 0; i < len(refs); i++ { + reg, repo, tag := parseImageReference(refs[i]) + if key == reg+"/v2/"+repo+"/manifests/"+tag { + break + } + } + if i == len(refs) { + return nil, fmt.Errorf("unexpected reference %s", key) + } + if phase == "invalid" && i < 3 { + if req.Method == http.MethodHead { + return newStringResponse(200, nil, ""), nil + } + return newStringResponse(200, nil, `registry unavailable`), nil + } + if req.Method == http.MethodHead { + return newStringResponse(200, map[string]string{"Content-Type": "application/vnd.oci.image.index.v1+json", "Docker-Content-Digest": fmt.Sprintf("sha256:index-%d", i)}, ""), nil + } + return newStringResponse(200, nil, fmt.Sprintf(`{"schemaVersion":2,"manifests":[{"digest":"sha256:platform-%d","platform":{"architecture":"amd64","os":"linux"}},{"digest":"sha256:arm-%d","platform":{"architecture":"arm64","os":"linux"}}]}`, i, i)), nil + })} + report := agentsdocker.Report{Timestamp: time.Now().UTC(), Agent: agentsdocker.AgentInfo{ID: "registry-proof", IntervalSeconds: 30}, Host: agentsdocker.HostInfo{Hostname: "registry-proof"}} + for i, ref := range refs { + inspect := baseInspect() + inspect.Config.Image = ref + reg, repo, _ := parseImageReference(ref) + current := fmt.Sprintf("sha256:index-%d", i) + if i == 2 { + current = fmt.Sprintf("sha256:platform-%d", i) + } + a := &Agent{logger: zerolog.Nop(), runtime: RuntimeDocker, prevContainerCPU: make(map[string]cpuSample), registryChecker: checker, docker: &fakeDockerClient{ + containerInspectWithRawFn: func(context.Context, string, bool) (containertypes.InspectResponse, []byte, error) { + return inspect, nil, nil + }, + containerStatsOneShotFn: func(context.Context, string) (dockerStatsResponseReader, error) { + return statsReader(t, containertypes.StatsResponse{}), nil + }, + imageInspectWithRawFn: func(context.Context, string) (image.InspectResponse, []byte, error) { + return image.InspectResponse{RepoDigests: []string{reg + "/" + repo + "@" + current}, Architecture: "amd64", Os: "linux"}, nil, nil + }, + }} + for attempt := 0; attempt < 2; attempt++ { + got, err := a.collectContainer(context.Background(), containertypes.Summary{ID: fmt.Sprintf("%064x", i+1), Names: []string{fmt.Sprintf("/container-%d", i)}, Image: ref, ImageID: fmt.Sprintf("image-%d", i), State: "running"}) + if err != nil { + t.Fatal(err) + } + s := got.UpdateStatus + if s == nil { + t.Fatal("missing status") + } + if s.CurrentDigest != current || s.UpdateAvailable { + t.Errorf("%s %s attempt %d: %+v", phase, ref, attempt, s) + } + if phase == "invalid" && i < 3 { + if s.Error == "" || s.LatestDigest != "" { + t.Errorf("non-manifest became update: %+v", s) + } + } else if s.Error != "" || s.LatestDigest != fmt.Sprintf("sha256:index-%d", i) { + t.Errorf("reference/platform mismatch: %+v", s) + } + if attempt == 1 { + report.Containers = append(report.Containers, got) + } + } + } + for key, n := range calls { + if n != 2 { + t.Errorf("%s requests=%d, expected one HEAD/GET with cache reuse", key, n) + } + } + reports = append(reports, report) + } + data, err := json.MarshalIndent(reports, "", " ") + if err != nil { + t.Fatal(err) + } + if path := os.Getenv("PULSE_REGISTRY_REPORT_PROOF"); path != "" { + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/dockeragent/registry.go b/internal/dockeragent/registry.go index d997ff56f..6b800a53d 100644 --- a/internal/dockeragent/registry.go +++ b/internal/dockeragent/registry.go @@ -433,6 +433,24 @@ func (r *RegistryChecker) fetchManifest(ctx context.Context, manifestURL, author if err != nil { return "", "", nil, fmt.Errorf("read manifest body: %w", err) } + if len(body) == 0 { + return "", "", nil, fmt.Errorf("no digest in response") + } + + // Only hash or trust digest metadata for an actual image manifest. A + // proxy/login/error page with HTTP 200 otherwise becomes a plausible but + // unrelated sha256 value, shared by every reference receiving that page. + var manifest struct { + SchemaVersion int `json:"schemaVersion"` + Config struct { + Digest string `json:"digest"` + } `json:"config"` + Manifests []json.RawMessage `json:"manifests"` + } + if err := json.Unmarshal(body, &manifest); err != nil || manifest.SchemaVersion != 2 || + (manifest.Config.Digest == "" && manifest.Manifests == nil) { + return "", "", nil, fmt.Errorf("registry returned a non-manifest response") + } digest := strings.Trim(resp.Header.Get("Docker-Content-Digest"), `"`) if digest == "" { digest = strings.Trim(resp.Header.Get("Etag"), `"`) diff --git a/internal/dockeragent/registry_coverage_test.go b/internal/dockeragent/registry_coverage_test.go index 4fcd7b946..362810ced 100644 --- a/internal/dockeragent/registry_coverage_test.go +++ b/internal/dockeragent/registry_coverage_test.go @@ -382,7 +382,7 @@ func TestRegistryChecker_FetchDigest_DigestHeaders(t *testing.T) { }) t.Run("GET fallback resolves manifest list", func(t *testing.T) { - manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}` + manifestBody := `{"schemaVersion":2,"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}` checker := &RegistryChecker{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -407,7 +407,7 @@ func TestRegistryChecker_FetchDigest_DigestHeaders(t *testing.T) { }) t.Run("GET fallback preserves index digest when HEAD identifies a manifest list", func(t *testing.T) { - manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}` + manifestBody := `{"schemaVersion":2,"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}` var methods []string checker := &RegistryChecker{ httpClient: &http.Client{ diff --git a/internal/dockeragent/registry_manifest_test.go b/internal/dockeragent/registry_manifest_test.go index 49224a3cb..79379826e 100644 --- a/internal/dockeragent/registry_manifest_test.go +++ b/internal/dockeragent/registry_manifest_test.go @@ -15,6 +15,7 @@ func TestRegistryChecker_ResolveManifestList(t *testing.T) { logger := zerolog.Nop() t.Run("resolve manifest list", func(t *testing.T) { manifestListBody := `{ + "schemaVersion": 2, "manifests": [ { "digest": "sha256:armv7", diff --git a/internal/dockeragent/registry_response_validation_test.go b/internal/dockeragent/registry_response_validation_test.go new file mode 100644 index 000000000..86488e6e9 --- /dev/null +++ b/internal/dockeragent/registry_response_validation_test.go @@ -0,0 +1,37 @@ +package dockeragent + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/rs/zerolog" +) + +// A successful HTTP status is not proof that a fallback body is a manifest: +// proxies and registry frontends can return the same HTML/JSON error for unrelated tags. +func TestRegistryCheckerRejectsNonManifestFallback(t *testing.T) { + for _, body := range []string{`registry unavailable`, `{"errors":[{"code":"UNAVAILABLE"}]}`, `{}`, `null`, `{"schemaVersion":2}`} { + t.Run(body, func(t *testing.T) { + checker := NewRegistryChecker(zerolog.Nop()) + checker.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.docker.io" || strings.HasSuffix(req.URL.Path, "/token") { + return newStringResponse(200, nil, `{"token":"synthetic"}`), nil + } + if req.Method == http.MethodHead { + return newStringResponse(200, nil, ""), nil + } + return newStringResponse(200, nil, body), nil + })} + for _, ref := range []string{"redis:8-alpine", "postgres:16-alpine", "ghcr.io/searxng/searxng:latest"} { + for attempt := 0; attempt < 2; attempt++ { + got := checker.CheckImageUpdate(context.Background(), ref, "sha256:current", "amd64", "linux", "") + if got.Error == "" || got.LatestDigest != "" || got.UpdateAvailable { + t.Errorf("%s attempt %d accepted non-manifest response: %+v", ref, attempt, got) + } + } + } + }) + } +} diff --git a/internal/monitoring/docker_registry_propagation_test.go b/internal/monitoring/docker_registry_propagation_test.go new file mode 100644 index 000000000..002895326 --- /dev/null +++ b/internal/monitoring/docker_registry_propagation_test.go @@ -0,0 +1,61 @@ +package monitoring + +import ( + "encoding/json" + "os" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" +) + +// Consumes the real collector's synthetic registry response qualification output. +func TestDockerRegistryReportQualification(t *testing.T) { + path := os.Getenv("PULSE_REGISTRY_REPORT_PROOF") + if path == "" { + t.Skip("set PULSE_REGISTRY_REPORT_PROOF to collector qualification output") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var reports []agentsdocker.Report + if err := json.Unmarshal(data, &reports); err != nil { + t.Fatal(err) + } + if len(reports) != 2 { + t.Fatal("expected failure and recovery reports") + } + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, false) + t.Cleanup(func() { mustSetMockEnabled(t, previous) }) + m := newTestMonitor(t) + var snapshots []models.StateSnapshot + for _, report := range reports { + host, err := m.ApplyDockerReport(report, nil) + if err != nil { + t.Fatal(err) + } + if len(host.Containers) != len(report.Containers) { + t.Fatal("container inventory changed") + } + for i, c := range host.Containers { + want := report.Containers[i].UpdateStatus + got := c.UpdateStatus + if got == nil || got.CurrentDigest != want.CurrentDigest || got.LatestDigest != want.LatestDigest || got.Error != want.Error || got.UpdateAvailable != want.UpdateAvailable { + t.Fatalf("ingest altered status: %+v vs %+v", got, want) + } + } + snapshots = append(snapshots, models.StateSnapshot{DockerHosts: []models.DockerHost{host}, LastUpdate: report.Timestamp}) + } + if path := os.Getenv("PULSE_REGISTRY_SNAPSHOT_PROOF"); path != "" { + data, err := json.MarshalIndent(snapshots, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index f05c9c053..3f304eab9 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -735,8 +735,14 @@ func physicalDiskFromReadStateView(view *unifiedresources.PhysicalDiskView) mode return models.PhysicalDisk{Wearout: unifiedresources.WearoutUnreported} } - return models.PhysicalDisk{ - ID: view.ID(), + // Preserve the provider key across the canonical read-state round trip. + // Older/synthetic views without source metadata retain their existing fallback. + sourceID := view.SourceID() + if sourceID == "" { + sourceID = view.ID() + } + disk := models.PhysicalDisk{ + ID: sourceID, Node: view.Node(), Instance: view.Instance(), DevPath: view.DevPath(), @@ -758,6 +764,8 @@ func physicalDiskFromReadStateView(view *unifiedresources.PhysicalDiskView) mode Collection: diskinventory.CloneStatus(view.Collection()), LastChecked: view.LastSeen(), } + return disk + } func physicalDiskIOFromUnifiedMeta(in *unifiedresources.PhysicalDiskIOMeta) *models.DiskIO { diff --git a/internal/monitoring/physical_disk_roundtrip_test.go b/internal/monitoring/physical_disk_roundtrip_test.go new file mode 100644 index 000000000..39ef3993a --- /dev/null +++ b/internal/monitoring/physical_disk_roundtrip_test.go @@ -0,0 +1,121 @@ +package monitoring + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Exercise the real skipped-poll path, not just the ID helper: canonical views +// are converted back into source state and then re-ingested by the adapter. +func TestPhysicalDiskSkippedPollPreservesSourceIdentity(t *testing.T) { + for _, device := range []string{"/dev/sdx", "sdx"} { + t.Run(device, func(t *testing.T) { + state := models.NewState() + nodes := []models.Node{{ID: "pve-node1", Name: "node1", Instance: "pve"}, {ID: "pve-node2", Name: "node2", Instance: "pve"}} + state.UpdateNodesForInstance("pve", nodes) + var disks []models.PhysicalDisk + for _, fixture := range []struct{ node, target string }{{"node1", ""}, {"node2", ""}, {"node1", "megaraid,0"}, {"node1", "megaraid,1"}} { + disks = append(disks, models.PhysicalDisk{ + ID: unifiedresources.ProxmoxPhysicalDiskSourceID("pve", fixture.node, device, "", fixture.target), + Instance: "pve", Node: fixture.node, DevPath: device, Target: fixture.target, + Model: "USB fixture", Size: 1024, Temperature: 37, Wearout: -1, LastChecked: time.Now(), + }) + } + state.UpdatePhysicalDisks("pve", disks) + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + adapter.PopulateFromSnapshot(state.GetSnapshot()) + m := &Monitor{state: state, resourceStore: adapter, lastPhysicalDiskPoll: map[string]time.Time{"pve": time.Now()}} + canonical := map[string]bool{} + for _, view := range adapter.PhysicalDisks() { + canonical[view.ID()] = true + } + if len(canonical) != len(disks) { + t.Fatalf("initial disks = %d, want %d", len(canonical), len(disks)) + } + for cycle := 0; cycle < 8; cycle++ { + m.maybePollPhysicalDisksAsync(context.Background(), "pve", &config.PVEInstance{}, nil, nil, nil, nil) + got := state.GetSnapshot().PhysicalDisks + if len(got) != len(disks) { + t.Fatalf("cycle %d: source count %d", cycle, len(got)) + } + wantIDs := map[string]bool{} + for _, disk := range disks { + wantIDs[disk.ID] = true + } + for _, disk := range got { + if !wantIDs[disk.ID] { + t.Fatalf("cycle %d: canonical ID leaked into provider state: %q", cycle, disk.ID) + } + if disk.DevPath != device || disk.Temperature != 37 || disk.Size != 1024 { + t.Fatalf("cycle %d: metadata lost: %+v", cycle, disk) + } + } + adapter.PopulateFromSnapshot(state.GetSnapshot()) + views := adapter.PhysicalDisks() + if len(views) != len(disks) { + t.Fatalf("cycle %d: view count = %d", cycle, len(views)) + } + for _, view := range views { + if !canonical[view.ID()] { + t.Fatalf("cycle %d: canonical identity churn: %s", cycle, view.ID()) + } + } + // Retain the JSON-visible projection as well as the typed read-state checks. + payload, err := json.Marshal(adapter.GetAll()) + if err != nil { + t.Fatal(err) + } + var resources []unifiedresources.Resource + if err := json.Unmarshal(payload, &resources); err != nil { + t.Fatal(err) + } + count := 0 + for _, resource := range resources { + if resource.Type != unifiedresources.ResourceTypePhysicalDisk { + continue + } + count++ + if !canonical[resource.ID] || resource.Proxmox == nil || !wantIDs[resource.Proxmox.SourceID] || resource.PhysicalDisk == nil || resource.PhysicalDisk.Temperature != 37 { + t.Fatalf("cycle %d: JSON disk identity/metadata lost: %+v", cycle, resource) + } + } + if count != len(disks) { + t.Fatalf("cycle %d: JSON disk count %d", cycle, count) + } + } + // A confirmed full inventory removal must still remove source-owned disks. + state.UpdatePhysicalDisks("pve", nil) + adapter.PopulateFromSnapshot(state.GetSnapshot()) + if got := len(adapter.PhysicalDisks()); got != 0 { + t.Fatalf("removed inventory retained %d disks", got) + } + }) + } +} + +func TestPhysicalDiskReadbackSourceIDFallback(t *testing.T) { + for _, resource := range []unifiedresources.Resource{ + {ID: "canonical"}, + {ID: "canonical", Proxmox: &unifiedresources.ProxmoxData{}}, + {ID: "canonical", Proxmox: &unifiedresources.ProxmoxData{SourceID: " native "}}, + } { + view := unifiedresources.NewPhysicalDiskView(&resource) + want := "canonical" + if resource.Proxmox != nil && resource.Proxmox.SourceID != "" { + want = "native" + } + if got := physicalDiskFromReadStateView(&view).ID; got != want { + t.Fatalf("ID = %q, want %q", got, want) + } + } + var view unifiedresources.PhysicalDiskView + if view.SourceID() != "" { + t.Fatal("nil resource has a source ID") + } +} diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index 7ae22239b..2ad14c902 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -2101,6 +2101,16 @@ func (v PhysicalDiskView) ID() string { return v.r.ID } +// SourceID returns the Proxmox-native inventory key, not the canonical +// resource ID. Polling adapters must preserve this key when writing views back +// into provider state; hashing a canonical ID again creates a new resource. +func (v PhysicalDiskView) SourceID() string { + if v.r == nil || v.r.Proxmox == nil { + return "" + } + return strings.TrimSpace(v.r.Proxmox.SourceID) +} + func (v PhysicalDiskView) Name() string { if v.r == nil { return "" diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 9336367df..349e6cd4a 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -226,6 +226,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/monitoring/availability_probe_agent_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_ps1_test.go", "scripts/installtests/install_sh_test.go", @@ -316,6 +317,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/monitoring/monitor_mock_alerts_test.go", "internal/monitoring/monitor_pve_cluster_refresh_test.go", "internal/monitoring/monitor_pve_guest_lxc_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "internal/monitoring/proxmox_large_cluster_poll_budget_test.go", "internal/monitoring/pve_protection_observation_test.go", "internal/monitoring/ratetracker_test.go", @@ -452,6 +454,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/monitoring/availability_probe_agent_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_ps1_test.go", "scripts/installtests/install_sh_test.go", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 8119a4eec..16c062b42 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4315,6 +4315,7 @@ class SubsystemLookupTest(unittest.TestCase): "internal/monitoring/availability_probe_agent_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", + "internal/monitoring/physical_disk_roundtrip_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_ps1_test.go", "scripts/installtests/install_sh_test.go", diff --git a/tests/qualification/disk-mounts/README.md b/tests/qualification/disk-mounts/README.md new file mode 100644 index 000000000..b7a1fa23c --- /dev/null +++ b/tests/qualification/disk-mounts/README.md @@ -0,0 +1,49 @@ +# Disk mount visibility (#2051) + +The Disks card previously clipped its mount list to 140px. Its thin nested +scrollbar was the only indication of additional mounts. The repair keeps all +mounts in the outer scrolling flow; aggregate usage and individual mount data +are unchanged. Large lists make the card taller rather than introducing another +scroll target. This deliberately avoids a global scrollbar-style change. + +## Reproduce + +Install locked root and frontend-modern npm dependencies and pinned Playwright +Firefox/Chromium browsers, then from the repository root: + +```sh +pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs +``` + +The isolated Vite fixture imports the production card and stylesheet. It uses +synthetic mount data, no backend, credentials or customer installation. It checks +2 and 24 mounts in light/dark themes, list clipping and keyboard End/Tab access +to the final mount and following control. Vite is shut down after the run. +The qualification HTML is not an application entry or production build input. + +## Evidence — 11 September 2026 + +Baseline 72809bc1cf71, Firefox 142.0.1: short list 54/54px; long list +clientHeight=140, scrollHeight=736, overflow=auto, maxHeight=140px. The no-clipping +assertion fails. After repair all eight Firefox 142.0.1 / Chromium 141.0.7390.37 +cases pass: short 54/54px, long 736/736px, overflow=visible, maxHeight=none. +Keyboard checks pass in each case. Component coverage additionally preserves +aggregate usage, all mounts and the empty state. + +This demonstrates the nested clipping and its removal with production CSS. It +does not establish why Firefox 140.15 ESR on the reporter's Debian desktop hides +its native scrollbar, nor reproduce their full drawer/estate. Reporter retest +and release availability remain distinct from this source/browser proof. + +The reporter's subsequent before/after-scroll crops in comment +https://github.com/rcourtman/Pulse/issues/2051#issuecomment-5632905322 were also +inspected: no evident pre-scroll thumb, then a narrow pale mark beside the disk +bars after scrolling. Removing the nested overflow eliminates that card's native +track/thumb entirely, rather than claiming to repair Firefox painting. The +fixture proves there is no hidden card overflow before keyboard scrolling; +outer-page navigation still reaches every mount. Exact desktop painting remains +unreproduced. + +Completion verification adds 600x500 and 1200x500 viewports: all 16 cases pass. +Full-page narrow Firefox dark and desktop Chromium light images were inspected; +all mounts and the focused following control remain visible without nested clipping. diff --git a/tests/qualification/disk-mounts/check.mjs b/tests/qualification/disk-mounts/check.mjs new file mode 100644 index 000000000..d557bd5df --- /dev/null +++ b/tests/qualification/disk-mounts/check.mjs @@ -0,0 +1,78 @@ +import { firefox, chromium } from "@playwright/test"; +import { createServer } from "../../../frontend-modern/node_modules/vite/dist/node/index.js"; +import assert from "node:assert/strict"; +process.chdir("frontend-modern"); +const server = await createServer({ + root: ".", + configFile: "vite.config.ts", + server: { port: 18791 }, +}); +await server.listen(); +try { + for (const engine of [firefox, chromium]) { + const browser = await engine.launch(); + try { + for (const width of [600, 1200]) + for (const theme of ["light", "dark"]) + for (const count of [2, 24]) { + const page = await browser.newPage({ + viewport: { width, height: 500 }, + }); + await page.goto( + `http://127.0.0.1:18791/qualification/disks/?theme=${theme}&count=${count}`, + ); + const last = page.getByTitle(`/mnt/disk-${count - 1}`, { + exact: true, + }); + await last.waitFor(); + const geometry = await last.evaluate((el) => { + const list = el.parentElement.parentElement.parentElement; + const style = getComputedStyle(list); + return { + height: list.clientHeight, + scrollHeight: list.scrollHeight, + overflow: style.overflowY, + maxHeight: style.maxHeight, + }; + }); + console.log( + JSON.stringify({ + browser: browser.version(), + theme, + width, + count, + ...geometry, + }), + ); + assert.equal( + geometry.height, + geometry.scrollHeight, + "mounts must not be clipped inside a nested scroller", + ); + await page.getByRole("button", { name: "Before disks" }).focus(); + await page.keyboard.press("End"); + await page.waitForTimeout(300); + await page.keyboard.press("Tab"); + assert.equal( + await page + .getByRole("button", { name: "After disks" }) + .evaluate((el) => el === document.activeElement), + true, + ); + const box = await last.boundingBox(); + assert.ok( + box && box.y >= 0 && box.y + box.height <= 500, + "last mount reachable through outer scrolling", + ); + if (process.env.DISK_SCREENSHOT_DIR && count === 24) { + await page.screenshot({path: `${process.env.DISK_SCREENSHOT_DIR}/${engine.name()}-${theme}-${width}.png`, fullPage:true}); + } + await page.close(); + } + } finally { + await browser.close(); + } + } +} finally { + await server.close(); +} diff --git a/tests/qualification/registry-response/README.md b/tests/qualification/registry-response/README.md new file mode 100644 index 000000000..ed58c6aef --- /dev/null +++ b/tests/qualification/registry-response/README.md @@ -0,0 +1,50 @@ +# Registry fallback response qualification + +This synthetic proof covers the Docker agent's registry response fallback, +container collection, report ingestion and `GET /api/resources`. It does not +contact a registry, run Docker, update a container or establish an affected +installation's cause. Registry fixtures use the reported references +`redis:8-alpine`, `postgres:16-alpine`, `ghcr.io/searxng/searxng:latest` and +`ghcr.io/paperless-ngx/paperless-ngx:latest` with synthetic digest values. + +The HTTP fixture first returns the same headerless HTTP 200 error page for +three references while the fourth is healthy. Those responses must produce +errors, no latest digest and no update indication, including cached checks. +A fresh checker then models agent restart with valid independent indexes; +matching index **or** platform digests must suppress updates. Exact request +paths and one HEAD/GET pair per reference establish reference isolation and +cache reuse. The production collector, not a hand-written status fixture, +produces the reports consumed by the next two probes. + +Run from the repository root, using a fresh private temporary directory: + +```sh +proof=$(mktemp -d) +export PULSE_REGISTRY_REPORT_PROOF="$proof/reports.json" +export PULSE_REGISTRY_SNAPSHOT_PROOF="$proof/snapshots.json" +go test -race ./internal/dockeragent -run 'TestCollectRegistryResponseIsolation|TestRegistryChecker' -count=1 +go test -race ./internal/monitoring -run '^TestDockerRegistryReportQualification$' -count=1 +go test -race ./internal/api/resourceapi -run '^TestResourcesRegistryResponseQualification$' -count=1 -v +``` + +The latter two probes skip without explicit input paths; they fail for missing +or malformed input when configured. Preserve both JSON files and command logs. +The endpoint probe calls the resource query handler directly, not the HTTP +authentication middleware. No credential or authorization acceptance is claimed. + +`fetchManifest` requires a schema-2 manifest envelope (config digest or index +manifest array) before using a GET fallback body or its digest metadata. This +is a minimal error-page discriminator, not full descriptor/cryptographic +validation. See the [OCI manifest specification](https://github.com/opencontainers/image-spec/blob/147f9c13cedb47a0c4d9a11a222961073d585877/manifest.md) +and [image index specification](https://github.com/opencontainers/image-spec/blob/147f9c13cedb47a0c4d9a11a222961073d585877/image-index.md), +both pinned to OCI Image Specification v1.1.1. +Existing HEAD digest handling, credentials, TLS, cache intervals and HTTP +error handling remain unchanged. A failed check is not proof of an up-to-date +image; consumers must retain the error field. No new UI state is introduced. + +Before attributing an incident to this mechanism, obtain safe response evidence +from the affected agent's network path: status, content type, digest headers, +and locally computed body hash, excluding authorization headers, cookies and +private response content. A matching synthetic symptom alone does not prove +that the reporter received the same response. Installed acceptance requires an +updated agent and verified error/recovery status on the same workload.