mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
fix: preserve disk identity and reject invalid registry responses
Carry verified maintenance repairs onto the published 6.4 line: preserve provider disk keys across read-state round trips, reject non-manifest registry bodies, and keep mount lists in parent scroll flow. Add regression coverage and qualification fixtures. Recover the unpublished range without changing runtime source; bind existing mount browser evidence to the correct published base. Preserve prior source and proof records. Contract-Neutral: Workflow-only PR concurrency changes preserve all jobs, triggers, permissions and frontend dependency security checks. Scheduling contract updated; no dependency security verification change is warranted. Change-source: pulse-maintainer
This commit is contained in:
parent
ea4c4a7147
commit
8509cf5d72
30 changed files with 744 additions and 32 deletions
2
.github/workflows/build-and-test.yml
vendored
2
.github/workflows/build-and-test.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/test-e2e.yml
vendored
2
.github/workflows/test-e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
7
frontend-modern/qualification/disks/index.html
Normal file
7
frontend-modern/qualification/disks/index.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
frontend-modern/qualification/disks/main.tsx
Normal file
22
frontend-modern/qualification/disks/main.tsx
Normal file
|
|
@ -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(
|
||||
() => (
|
||||
<main class="bg-surface text-base-content p-4" style={{ width: '440px' }}>
|
||||
<button>Before disks</button>
|
||||
<DisksCard disks={disks} />
|
||||
<button>After disks</button>
|
||||
</main>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
|
|
@ -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('<For each={props.disks}>');
|
||||
expect(source).toContain('<StackedDiskBar');
|
||||
});
|
||||
|
||||
it('keeps one canonical agent-host metric history group catalog', () => {
|
||||
expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([
|
||||
'utilization',
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export const DisksCard: Component<DisksCardProps> = (props) => {
|
|||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="max-h-[140px] overflow-y-auto custom-scrollbar space-y-2">
|
||||
{/* Keep mounts in the parent scroll flow: overlay scrollbars can hide a nested list. */}
|
||||
<div class="space-y-2" data-testid="disks-card-mounts">
|
||||
<For each={props.disks}>
|
||||
{(disk) => {
|
||||
const total = disk.total ?? 0;
|
||||
|
|
|
|||
|
|
@ -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(() => <DisksCard disks={disks} />);
|
||||
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(() => <DisksCard disks={[]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
114
internal/dockeragent/collect_registry_response_test.go
Normal file
114
internal/dockeragent/collect_registry_response_test.go
Normal file
|
|
@ -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, `<html>registry unavailable</html>`), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"), `"`)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
37
internal/dockeragent/registry_response_validation_test.go
Normal file
37
internal/dockeragent/registry_response_validation_test.go
Normal file
|
|
@ -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{`<html>registry unavailable</html>`, `{"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
internal/monitoring/docker_registry_propagation_test.go
Normal file
61
internal/monitoring/docker_registry_propagation_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
121
internal/monitoring/physical_disk_roundtrip_test.go
Normal file
121
internal/monitoring/physical_disk_roundtrip_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
49
tests/qualification/disk-mounts/README.md
Normal file
49
tests/qualification/disk-mounts/README.md
Normal file
|
|
@ -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.
|
||||
78
tests/qualification/disk-mounts/check.mjs
Normal file
78
tests/qualification/disk-mounts/check.mjs
Normal file
|
|
@ -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();
|
||||
}
|
||||
50
tests/qualification/registry-response/README.md
Normal file
50
tests/qualification/registry-response/README.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue