From 956ea2bf3b0f89ea8f646be3a0d2820f00e82f78 Mon Sep 17 00:00:00 2001 From: Konstantins Feofantovs Date: Wed, 16 Sep 2026 14:23:25 +0300 Subject: [PATCH 1/2] ci: the linter runs without its analysis cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restored cache reported six SA5011 findings in files the branch did not touch, twice, on a tree byte-identical to a passing main — the changed files were behind `//go:build e2e`, which `.golangci.yml` sets no build tag for, so the linter never read them. Deleting the cache entry and re-running gave 0 issues. The findings were false: each site calls t.Fatal in the nil branch before dereferencing, and t.Fatal calls runtime.Goexit. Seeing that takes knowing testing.common.Fatalf does not return, which a partial analysis cache drops. warm 15.6s, cold 39.2s — 24 seconds a run Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeZECJXTDHL6Nts95eKMJA --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23650e0..f31d70b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,13 @@ jobs: - uses: golangci/golangci-lint-action@v9 with: version: v2.11.4 + # No analysis cache. On 2026-09-16 a restored one reported six + # SA5011 findings in files the branch did not touch, twice, on a + # tree byte-identical to a passing main; a cold run found none. + # Seeing that `t.Fatal` does not return takes knowledge of the + # testing package that a partial cache drops. 24s a run buys the + # whole class of false red. + skip-cache: true proto: name: Payload schema (buf) From b3691ed972c465e16b02c3c8458316c74cf5f02b Mon Sep 17 00:00:00 2001 From: Konstantins Feofantovs Date: Wed, 16 Sep 2026 14:54:56 +0300 Subject: [PATCH 2/2] feat: a confirmed endpoint is named on the port it answered on (ADR 0082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three facts about a workload's debug surface already shipped. The binary links net/http/pprof, per digest. The process listens on these ports, some loopback, per workload. Six targets confirmed, per nobody — the prober's answer left the cluster as four integers. So the payloads could not say which port served the mux. In the golden, shop/web links pprof and binds 8080 and loopback 6060; one of those serves /debug/pprof/heap to anything that can reach the pod and the other serves it to nothing, and the difference was unrecoverable. An inference from "links pprof" and "has a routable port" is right most of the time and wrong silently, which is the worst thing a report can be. The answer is stamped onto the port, in the payload that already names the workload: the prober's key is {digest, port} and a record carries both, so the join is a lookup. Prober.Stamp does it — the package that owns an answer writes it down — and the store stays a pure join of what nodes reported. Empty is "not asked", never "not pprof". Candidates drops loopback ports before the funnel, so no-answer is the common case; a boolean would have collapsed the two claims this payload needs to keep apart. inventory.Port is new and carries the node's observation plus the controller's answer. nodescan.ListeningPort keeps carrying only what a node can know, which is what crosses the channel — one field on the node's type that the node never fills would be shorter and would blur that line. No new request, no new read, no new connection: the probe ADR 0057 authorised already established this, and what changes is where it is written. go test ./... pass golangci-lint (pinned v2.11.4) 0 issues make inventory-e2e PASS (726s) listening_ports names the endpoint: map[6060:confirmed 9090:] Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeZECJXTDHL6Nts95eKMJA --- cmd/agent/main.go | 10 +- ...confirmed-endpoint-is-named-on-its-port.md | 116 ++++++++++++++++++ docs/adr/README.md | 1 + docs/backend-requirements.md | 9 ++ docs/prose_test.go | 17 +-- docs/security.md | 2 +- internal/inventory/store.go | 25 +++- internal/inventory/store_test.go | 4 +- internal/pprofprobe/probe.go | 23 ++++ internal/pprofprobe/probe_test.go | 58 ++++++++- internal/sink/spool_test.go | 6 +- .../sink/testdata/listening-ports.golden.json | 6 +- test/e2e/inventory_test.go | 54 ++++++++ 13 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0082-a-confirmed-endpoint-is-named-on-its-port.md diff --git a/cmd/agent/main.go b/cmd/agent/main.go index cf8b130..29f22a0 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -541,8 +541,14 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac } // Where those processes accept connections. Structural like the // inventory, and separate from it because it changes without the build - // changing (ADR 0056 §3). - if err := spool.WriteListeningPorts(time.Now(), goStore.PortSnapshot()); err != nil { + // changing (ADR 0056 §3). The prober stamps each port with what its one + // request answered, which is the only part of this payload the + // controller learned rather than received (ADR 0082). + ports := goStore.PortSnapshot() + if prober != nil { + ports = prober.Stamp(ports) + } + if err := spool.WriteListeningPorts(time.Now(), ports); err != nil { logger.Error("spooling listening ports", "error", err) } // Build facts are keyed by image digest and immutable for it, so each is diff --git a/docs/adr/0082-a-confirmed-endpoint-is-named-on-its-port.md b/docs/adr/0082-a-confirmed-endpoint-is-named-on-its-port.md new file mode 100644 index 0000000..542ccba --- /dev/null +++ b/docs/adr/0082-a-confirmed-endpoint-is-named-on-its-port.md @@ -0,0 +1,116 @@ +# 0082. A confirmed endpoint is named on the port it answered on + +Date: 2026-09-16 + +Status: Accepted + +Adds a `pprof` field to each port of `listening_ports`, carrying what the +prober's one request to that port answered. + +## Context + +Three facts about a workload's debug surface already ship, and the fourth — the +one that makes the other three a finding rather than a lead — does not. + +| Fact | Payload | Identity | +|---|---|---| +| the binary links `net/http/pprof` | `go_build.pprof_endpoint` | image digest | +| the process listens on a port, loopback or not | `listening_ports` | workload, container, digest | +| six targets confirmed, eleven absent, two unreachable | `collection_coverage.pprof` | **none — counts only** | +| *which* port of *which* workload answered | — | **nowhere** | + +The prober knows. It asks about a `Target{ImageDigest, Port}` and files the +answer under that pair (`internal/pprofprobe/probe.go`). What leaves the cluster +is `Snapshot()`, four integers. + +The gap cannot be closed by joining what ships. Take the `listening_ports` +golden: `shop/web` links pprof, listens on 8080 and on 6060 bound to loopback. +Which of the two serves the mux? The payloads cannot say, and the difference is +the whole finding — a pprof mux on a routable port serves +`/debug/pprof/heap`, the contents of the process's memory, to anything that can +reach the pod, and `/debug/pprof/cmdline`, the argv that CLAUDE.md invariant 4 +refuses to collect. On a loopback port it serves them to nothing. + +An inference from "links pprof" and "has a routable port" is right most of the +time and wrong silently. A report that presents it as a finding spends the +credibility the report exists to build. + +## Decision + +**1. The answer is stamped onto the port, in the payload that already names +it.** `listening_ports` records carry `{namespace, workload, container, digest}` +and a list of ports; the prober's key is `{digest, port}`. The join is a lookup, +so no new payload, no new kind, and no new identity: the workload was already +named in this payload, on this record, one field to the left. + +Stamping happens where the payload is assembled, and the prober does it — +`Prober.Stamp` — because the package that owns an answer should be the one that +writes it down. The store stays a pure join of what nodes reported. + +**2. The value is a word, and its absence is not a fourth word.** `confirmed`, +`absent`, `unreachable` — the prober's own three states. A port nothing asked +about carries no field at all, and every loopback port is such a port: +`Candidates` drops them before the funnel, because nothing outside the pod can +open them. + +A boolean would have collapsed "asked, and it is not pprof" into "not asked", +and the second is the common case. Proof of absence and absence of proof are +different claims and this payload makes both, so it must say which it is making. + +**3. The controller's type is not the node's.** `inventory.Port` is new and +carries the node's observation plus the controller's answer; +`nodescan.ListeningPort` keeps carrying only what a node can know, which is what +crosses the node→controller channel. The alternative — one field on the node's +type that the node never fills — is one line shorter and blurs the line the +channel exists to draw (ADR 0009, ADR 0052's shape). + +**4. Why this names a workload where the coverage payload does not.** +[ADR 0054](0054-coverage-says-how-much-was-hidden-never-what.md) reports counts +and no identities, and CLAUDE.md invariant 6 forbids the identity of a +*filtered-out* object from leaving the cluster. Neither is in the way here, and +saying why matters more than the field does. + +The invariant protects the customer's refusal: they said do not look, and we do +not report on whom. This record is the opposite — an admitted workload, under +collection, and the fact is about that workload's own configuration, published +to that workload's own operator. A workload the profiling opt-out excluded is +never probed at all (ADR 0071), so it has no answer to stamp, and its silence +here is the same silence as everywhere else. + +**5. The objection, named.** The payload becomes a list of debug endpoints +reachable inside the customer's cluster. Weak — the ports are already in this +payload, the mux answers any pod on the network whether or not we write it down, +and a reader of the spool is already inside — but weak is not absent, and an +unnamed objection is a hole in the argument (ADR 0039's discipline). It is +accepted because the alternative is not telling the operator that their memory +is readable. + +## Consequences + +**Easier.** The finding a report can make becomes provable instead of inferred: +this workload, this port, answered the pprof index. The loopback case, which is +the correct configuration and the common one, is legible as correct rather than +indistinguishable from the dangerous one. + +The three coverage counters keep their meaning and are not replaced. They count +targets, including targets of workloads that have since gone; this field +describes ports that exist now. + +**Harder / given up.** A second type where there was one, and a conversion in +`PortSnapshot` to go with it. The cost is real and it buys the channel's +boundary: a reviewer asking "can a node claim an endpoint is confirmed?" reads +the type and sees that it cannot. + +The field describes the latest answer, not a history. A port that was confirmed +and is now unreachable reads as unreachable, and the payload does not say it +ever answered — the prober's own retry rule (`unreachableRetry`) decides how +long that takes to correct. + +**Not changed.** No new request, no new read, no new connection: the answer +being published was already established by the probe ADR 0057 authorised, and +this ADR moves nothing but where it is written. `go_build.pprof_endpoint` still +answers the question about the image, and this field the question about the +address. + +This ADR records decisions implemented in the same pull request, per the process +in [`README.md`](README.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index ba045ee..04a19ff 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -139,3 +139,4 @@ line to paste. - [0079. The last pass writes, and then ships what it wrote](0079-the-last-pass-writes-then-ships.md) - [0080. The index page already counts the goroutines](0080-the-index-page-already-counts-the-goroutines.md) - [0081. A weakened security default is a build fact like any other](0081-a-weakened-default-is-a-build-fact.md) +- [0082. A confirmed endpoint is named on the port it answered on](0082-a-confirmed-endpoint-is-named-on-its-port.md) diff --git a/docs/backend-requirements.md b/docs/backend-requirements.md index 2c5bf35..6345193 100644 --- a/docs/backend-requirements.md +++ b/docs/backend-requirements.md @@ -614,6 +614,15 @@ addressed from this payload. Records are keyed by image digest as well as container and MUST NOT be carried across a rollout; a port absent from the latest report is a port no process still binds. +A port MAY carry `pprof`, holding `confirmed`, `absent` or `unreachable` — what +the agent's one request to that port answered +([ADR 0082](adr/0082-a-confirmed-endpoint-is-named-on-its-port.md)). **An absent +`pprof` field means the port was not asked about and MUST NOT be read as +`absent`**: loopback ports are never asked, nor is any port of a workload the +profiling opt-out excludes. Only `confirmed` on a port not marked `loopback` +states that the pprof index answered where another pod could reach it. The value +is the latest answer and carries no history. + **`workload_policy` Service endpoint counts are per address family and MUST NOT be summed across them** ([ADR 0051](adr/0051-topology-routing-is-asked-and-not-granted.md)). A dual-stack Service lists each pod once in an IPv4 slice and once in an IPv6 diff --git a/docs/prose_test.go b/docs/prose_test.go index af07346..923b5bd 100644 --- a/docs/prose_test.go +++ b/docs/prose_test.go @@ -42,14 +42,15 @@ var ceilings = []struct { // GODEBUG names are read out of the build, on a second axis from the two // the promise named before (ADR 0081). {"security.md", 1116, 361}, - // Each raise names its obligation here too. The request body is encoded, so - // the implementer must decode it and stop reading a payload's size off the - // request (ADR 0076); a journal window states whether it is finished - // (ADR 0078); a new kind arrives whose samples are gauges, where the obvious - // ingest — adding them up — is the wrong one (ADR 0080); `godebug` carries - // names where neither value is safe by convention (ADR 0081); and the filter - // counters count decisions, so adding them no longer recovers a pod count. - {"backend-requirements.md", 806, 635}, + // Each raise names its obligation here too. The body is encoded, so a + // payload's size is no longer the request's (ADR 0076); a journal window + // states whether it is finished (ADR 0078); gauge samples arrive, where + // adding them up is the wrong ingest (ADR 0080); `godebug` carries names + // where neither value is safe by convention (ADR 0081); the filter counters + // count decisions, so adding them recovers no pod count; and a port's + // `pprof` is absent when nothing asked, which is not the claim `absent` + // makes (ADR 0082). + {"backend-requirements.md", 815, 644}, } func TestNoCommentRunIsLongerThanAPointerToAnADR(t *testing.T) { diff --git a/docs/security.md b/docs/security.md index 9fea977..79e76e8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -535,7 +535,7 @@ raise them, and nothing can ask for data sooner. | `process_peaks` | Per collected workload container and build: the largest `VmHWM` among its Go processes, how many processes that was taken over, and the range of permitted CPU counts. A **floor** under the container's peak, never the figure the OOM killer compares against — the cgroup also holds page cache and every other process in it ([ADR 0052](adr/0052-the-peak-the-kernel-remembers.md)). Beside it, the largest sample taken of each process's footprint: resident memory split into the program's own pages and file-backed ones, the memory it shares with its replicas and the memory nothing else maps, its OS threads, and how many file descriptors it held against the ceiling in force ([ADR 0061](adr/0061-what-the-process-holds-beside-its-peak.md)). **What each descriptor points at is never read** — they are counted, not followed | no | | `go_build` | Per image digest, written once: Go version, main module, each dependency module's **path and version**, allow-listed build settings, two allow-listed GODEBUG defaults, and whether `net/http/pprof` is linked into the build. This is a bill of materials for the build — see [§10.4](#104-the-build-inventory-is-a-bill-of-materials) | no | | `process_counters` | Per collected workload container and build: what its processes did since each node's previous scan pass — time on CPU and time waiting for one, that CPU time split between your code and the kernel acting for it, page faults that reached storage, context switches, and bytes that moved to and from block storage. Each record carries the process-time the numbers cover, because a difference without its interval is not a rate ([ADR 0062](adr/0062-the-counters-and-the-window-they-cover.md)). Sums only — the agent never ships the division | no | -| `listening_ports` | Per collected workload container and build: the TCP ports its Go processes accept connections on, each with whether it is bound to loopback. Read from the processes' own sockets, so a port nothing declared is here and a declared port nothing binds is not ([ADR 0056](adr/0056-a-pprof-endpoint-is-proved-not-probed.md)). No address, and no connection the workload has open | no | +| `listening_ports` | Per collected workload container and build: the TCP ports its Go processes accept connections on, each with whether it is bound to loopback, and — for a port the agent asked — whether the Go pprof index answered there ([ADR 0082](adr/0082-a-confirmed-endpoint-is-named-on-its-port.md)). Read from the processes' own sockets, so a port nothing declared is here and a declared port nothing binds is not ([ADR 0056](adr/0056-a-pprof-endpoint-is-proved-not-probed.md)). No address, and no connection the workload has open | no | | `ebpf_profile` | One capture: allow-list-filtered symbolized pprof bytes, keyed by workload, image digest and capture window | no | | `pprof_profile` | One ten-second CPU profile fetched from the workload's own `/debug/pprof` endpoint, allow-list-filtered, under the same key ([ADR 0058](adr/0058-the-pull-starts-a-profiler-and-the-binary-says-whose-code-it-is.md)). The mappings — the executable's path on disk and its build ID — the sample labels, and the full source paths are removed with the redacted frames, so what remains is function names your allow-list admits, base file names and line numbers. Beside it travel the counts of what was redacted, never what | no | | `goroutine_counts` | Per sampled process and window: how many goroutines it was running at each reading, and the pod, container and build it was read from ([ADR 0080](adr/0080-the-index-page-already-counts-the-goroutines.md)). A count and a timestamp. No stack, no function name and nothing about what those goroutines were doing | no | diff --git a/internal/inventory/store.go b/internal/inventory/store.go index d1b4148..42e07bc 100644 --- a/internal/inventory/store.go +++ b/internal/inventory/store.go @@ -175,6 +175,20 @@ type PortKey struct { ImageDigest string `json:"image_digest,omitempty"` } +// Port is one port of one build, as the controller publishes it: what the node +// observed, plus what the controller established about it afterwards. Distinct +// from nodescan.ListeningPort, which is what crossed the channel and carries +// only what a node can know (ADR 0082). +type Port struct { + Port int `json:"port"` + Loopback bool `json:"loopback,omitempty"` + // Pprof is what the prober's one request to this port answered — + // "confirmed", "absent" or "unreachable" — and is empty for a port nothing + // asked about, which every loopback port is. Empty is "not asked", never + // "not pprof" (ADR 0082 §2). + Pprof string `json:"pprof,omitempty"` +} + // PortRecord is what the processes of one workload container, of one build, // accept connections on — merged across every replica the agent can see. // @@ -185,7 +199,7 @@ type PortKey struct { type PortRecord struct { PortKey // Ports is the union of what the replicas listen on, in port order. - Ports []nodescan.ListeningPort `json:"ports"` + Ports []Port `json:"ports"` // AssertedAt is the oldest of the contributing nodes' latest reports — the // bound on how current this record is. A node that stopped reporting keeps // contributing what it last stated, so without this the record would be @@ -915,7 +929,14 @@ func (s *Store) PortSnapshot() []PortRecord { if len(merged) == 0 { continue } - out = append(out, PortRecord{PortKey: pk, Ports: merged, AssertedAt: asserted}) + // Merging stays on the node's own type — it is the node's claim being + // reconciled — and the controller's type begins here, where the record + // leaves the store. + ports := make([]Port, len(merged)) + for i, m := range merged { + ports[i] = Port{Port: m.Port, Loopback: m.Loopback} + } + out = append(out, PortRecord{PortKey: pk, Ports: ports, AssertedAt: asserted}) } sort.Slice(out, func(i, j int) bool { if out[i].Key != out[j].Key { diff --git a/internal/inventory/store_test.go b/internal/inventory/store_test.go index a881024..88af4a3 100644 --- a/internal/inventory/store_test.go +++ b/internal/inventory/store_test.go @@ -684,7 +684,7 @@ func TestPortsMergeAcrossReplicasAndNodes(t *testing.T) { if len(ports) != 1 { t.Fatalf("ports = %+v, want one record", ports) } - want := []nodescan.ListeningPort{{Port: 6060}, {Port: 8080}, {Port: 9090}} + want := []Port{{Port: 6060}, {Port: 8080}, {Port: 9090}} if !reflect.DeepEqual(ports[0].Ports, want) { t.Errorf("ports = %+v, want %+v (6060 is reachable on node-2)", ports[0].Ports, want) } @@ -710,7 +710,7 @@ func TestAPortDoesNotOutliveTheProcessThatBoundIt(t *testing.T) { if len(ports) != 1 { t.Fatalf("ports = %+v, want one record", ports) } - if want := []nodescan.ListeningPort{{Port: 8080}}; !reflect.DeepEqual(ports[0].Ports, want) { + if want := []Port{{Port: 8080}}; !reflect.DeepEqual(ports[0].Ports, want) { t.Errorf("ports = %+v, want %+v (6060 was not in the latest report)", ports[0].Ports, want) } } diff --git a/internal/pprofprobe/probe.go b/internal/pprofprobe/probe.go index 4acdca7..000b095 100644 --- a/internal/pprofprobe/probe.go +++ b/internal/pprofprobe/probe.go @@ -270,6 +270,29 @@ func (p *Prober) Confirmed(candidates []Candidate) []Candidate { return out } +// Stamp writes what the prober established onto each port of each record, and +// returns the records. The answer is about a build's port — the same +// {digest, port} pair the prober asked about — so the join is a lookup, not an +// inference (ADR 0082 §1). +// +// A port nothing asked about is left empty rather than marked absent: Candidates +// drops loopback ports, so "no answer" is the common case and must not read as +// "not pprof". +func (p *Prober) Stamp(records []inventory.PortRecord) []inventory.PortRecord { + p.mu.Lock() + defer p.mu.Unlock() + for _, rec := range records { + for i, port := range rec.Ports { + a, ok := p.answers[Target{ImageDigest: rec.ImageDigest, Port: port.Port}] + if !ok || a.state == StateUnknown { + continue + } + rec.Ports[i].Pprof = a.state.String() + } + } + return records +} + // Coverage is what the prober has established, for the collection-coverage // payload. Counts only: which workload was asked about is already in the // inventory beside it, and which was refused is not a name this reports diff --git a/internal/pprofprobe/probe_test.go b/internal/pprofprobe/probe_test.go index 347fea8..7b1fac9 100644 --- a/internal/pprofprobe/probe_test.go +++ b/internal/pprofprobe/probe_test.go @@ -12,7 +12,6 @@ import ( "time" "github.com/RebuildStackCo/runtime-agent/internal/inventory" - "github.com/RebuildStackCo/runtime-agent/internal/nodescan" ) func testProber(t *testing.T, addr Address) *Prober { @@ -218,14 +217,14 @@ func TestCandidatesAreTheFunnelsOutput(t *testing.T) { Key: inventory.Key{Namespace: "shop", WorkloadKind: "Deployment", WorkloadName: "web", Container: "app"}, ImageDigest: "sha256:linked", }, - Ports: []nodescan.ListeningPort{{Port: 8080}, {Port: 6060, Loopback: true}}, + Ports: []inventory.Port{{Port: 8080}, {Port: 6060, Loopback: true}}, }, { PortKey: inventory.PortKey{ Key: inventory.Key{Namespace: "shop", WorkloadKind: "StatefulSet", WorkloadName: "db", Container: "db"}, ImageDigest: "sha256:plain", }, - Ports: []nodescan.ListeningPort{{Port: 5432}}, + Ports: []inventory.Port{{Port: 5432}}, }, } got := Candidates(ports, map[string][]string{"sha256:linked": {"github.com/acme/web"}}) @@ -284,3 +283,56 @@ func hostOf(t *testing.T, url string) string { } return url[len(prefix):] } + +// The answer is about a build's port, and the join to a record is that pair. +// Everything else on the record — which workload, which container — plays no +// part, so two workloads of one image get the same answer from one probe. +func TestStampWritesTheAnswerOntoTheMatchingPort(t *testing.T) { + p := New(func(Candidate) (string, bool) { return "", false }, slog.New(slog.DiscardHandler)) + p.record(Target{ImageDigest: "sha256:a", Port: 8080}, StateConfirmed) + p.record(Target{ImageDigest: "sha256:a", Port: 9090}, StateAbsent) + p.record(Target{ImageDigest: "sha256:b", Port: 8080}, StateUnreachable) + + records := []inventory.PortRecord{ + { + PortKey: inventory.PortKey{ + Key: inventory.Key{Namespace: "shop", WorkloadName: "web", Container: "app"}, + ImageDigest: "sha256:a", + }, + // 6060 was never asked about; 8080 and 9090 were. + Ports: []inventory.Port{{Port: 8080}, {Port: 9090}, {Port: 6060, Loopback: true}}, + }, + { + PortKey: inventory.PortKey{ + Key: inventory.Key{Namespace: "shop", WorkloadName: "api", Container: "app"}, + ImageDigest: "sha256:b", + }, + Ports: []inventory.Port{{Port: 8080}}, + }, + } + + got := p.Stamp(records) + want := [][]string{{"confirmed", "absent", ""}, {"unreachable"}} + for i, rec := range got { + for j, port := range rec.Ports { + if port.Pprof != want[i][j] { + t.Errorf("record %d port %d = %q, want %q", i, port.Port, port.Pprof, want[i][j]) + } + } + } +} + +// A port the prober never asked about stays empty even when the same port +// number was answered for another build: the digest is half the key. +func TestStampDoesNotCarryAnAnswerAcrossBuilds(t *testing.T) { + p := New(func(Candidate) (string, bool) { return "", false }, slog.New(slog.DiscardHandler)) + p.record(Target{ImageDigest: "sha256:a", Port: 8080}, StateConfirmed) + + got := p.Stamp([]inventory.PortRecord{{ + PortKey: inventory.PortKey{ImageDigest: "sha256:other"}, + Ports: []inventory.Port{{Port: 8080}}, + }}) + if got[0].Ports[0].Pprof != "" { + t.Errorf("pprof = %q for a build nothing was asked about, want empty", got[0].Ports[0].Pprof) + } +} diff --git a/internal/sink/spool_test.go b/internal/sink/spool_test.go index a8b0f22..3d77cc9 100644 --- a/internal/sink/spool_test.go +++ b/internal/sink/spool_test.go @@ -835,8 +835,8 @@ func fixedPorts() []inventory.PortRecord { ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", }, AssertedAt: capturedAt.Add(-30 * time.Second), - Ports: []nodescan.ListeningPort{ - {Port: 8080}, + Ports: []inventory.Port{ + {Port: 8080, Pprof: "confirmed"}, {Port: 6060, Loopback: true}, }, }, @@ -848,7 +848,7 @@ func fixedPorts() []inventory.PortRecord { }, }, AssertedAt: capturedAt.Add(-time.Hour), - Ports: []nodescan.ListeningPort{{Port: 5432}}, + Ports: []inventory.Port{{Port: 5432, Pprof: "absent"}}, }, } } diff --git a/internal/sink/testdata/listening-ports.golden.json b/internal/sink/testdata/listening-ports.golden.json index f4cd7c1..87b5a13 100644 --- a/internal/sink/testdata/listening-ports.golden.json +++ b/internal/sink/testdata/listening-ports.golden.json @@ -11,7 +11,8 @@ "image_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "ports": [ { - "port": 8080 + "port": 8080, + "pprof": "confirmed" }, { "port": 6060, @@ -27,7 +28,8 @@ "container": "db", "ports": [ { - "port": 5432 + "port": 5432, + "pprof": "absent" } ], "asserted_at": "2026-08-06T09:12:00Z" diff --git a/test/e2e/inventory_test.go b/test/e2e/inventory_test.go index 95748e8..db7e49d 100644 --- a/test/e2e/inventory_test.go +++ b/test/e2e/inventory_test.go @@ -366,6 +366,10 @@ func checkTheOptOutIsCounted(ctx context.Context, t *testing.T, config *rest.Con if err := json.Unmarshal([]byte(raw), &payload); err != nil { t.Fatalf("collection_coverage payload not valid JSON: %v", err) } + // At least one, never exactly one: the counter is a cluster-wide + // aggregate carrying no identity (ADR 0054), so a pod excluded in + // some other namespace counts here too — an abandoned run's pod + // does, and was measured doing so. if n := payload.Filter.ExcludedPodAnnotation; n >= 1 { t.Logf("coverage explains the departure: excluded_pod_annotation = %d", n) return @@ -379,6 +383,55 @@ func checkTheOptOutIsCounted(ctx context.Context, t *testing.T, config *rest.Con } } +// checkTheConfirmedPortIsNamed asserts the answer reached the port it is about. +// The sample binds two: 6060 routable and serving the pprof index, 9090 on +// loopback and never asked. One must say so and the other must stay silent — +// an empty field is "not asked", never "not pprof" (ADR 0082 §2). +func checkTheConfirmedPortIsNamed(ctx context.Context, t *testing.T, config *rest.Config, cs kubernetes.Interface, ns, pod string) { + t.Helper() + deadline := time.Now().Add(6 * time.Minute) + for { + raw, ok := readSpoolFile(ctx, t, config, cs, ns, pod, listeningPortsSpoolPath) + if ok { + var payload struct { + Records []struct { + Namespace string `json:"namespace"` + WorkloadName string `json:"workload_name"` + Ports []struct { + Port int `json:"port"` + Loopback bool `json:"loopback"` + Pprof string `json:"pprof"` + } `json:"ports"` + } `json:"records"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("listening ports payload not valid JSON: %v", err) + } + got := map[int]string{} + for _, r := range payload.Records { + if r.Namespace != ns || r.WorkloadName != "goworkload" { + continue + } + for _, port := range r.Ports { + got[port.Port] = port.Pprof + } + } + if got[6060] == "confirmed" { + if got[9090] != "" { + t.Errorf("port 9090 carries pprof=%q; a loopback port is never asked", got[9090]) + } + t.Logf("listening_ports names the endpoint: %v", got) + return + } + } + if time.Now().After(deadline) { + t.Error("listening_ports never marked :6060 confirmed; the coverage counted an endpoint nothing names") + return + } + time.Sleep(10 * time.Second) + } +} + // goInventorySnapshot is one read of the spool file. CapturedAt is what makes a // negative reading meaningful: the kind supersedes in place under one file name, // so a reader that does not date the payload cannot tell a record that left from @@ -536,6 +589,7 @@ func checkEndpointConfirmed(ctx context.Context, t *testing.T, config *rest.Conf } if payload.Pprof.Confirmed > 0 { t.Logf("endpoint discovery confirmed %d target(s)", payload.Pprof.Confirmed) + checkTheConfirmedPortIsNamed(ctx, t, config, cs, ns, pod) return } }