Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions charts/runtime-agent/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,6 @@ where a knob read as deny-by-default and was inert. An install that cannot
produce its own output should not start.
*/}}
{{- define "runtime-agent.validate" -}}
{{- if eq .Values.profile "pprof" -}}
{{- fail "profile \"pprof\" is not installable: no pprof puller exists yet (docs/security.md §3 marks it [planned]). Use metrics-only, inventory or ebpf." -}}
{{- end -}}
{{- if not (has .Values.profile (list "metrics-only" "inventory" "ebpf")) -}}
{{- fail (printf "unknown profile %q: expected metrics-only, inventory or ebpf" .Values.profile) -}}
{{- end -}}
Expand Down
8 changes: 8 additions & 0 deletions charts/runtime-agent/templates/controller-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ data:
{{- else }}
enabled: false
{{- end }}
pprof:
{{- if include "runtime-agent.nodeEnabled" . }}
enabled: {{ .Values.profiling.pprof.enabled }}
{{- else }}
# No node DaemonSet in this profile, so neither the linked-package fact
# nor the bound port is ever read and there is nothing to confirm.
enabled: false
{{- end }}
15 changes: 12 additions & 3 deletions charts/runtime-agent/templates/controller-networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,18 @@ spec:
app.kubernetes.io/name: runtime-agent
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: controller
# Ingress only. The controller's egress is the API server today and a backend
# later; restricting it belongs with the change that gives it something to
# egress to, and a policy written now would have to be rewritten then.
# Still ingress only, and now deliberately so rather than pending.
#
# The controller does egress to workloads since ADR 0057, which is the change
# this comment used to defer to. An egress policy would have to permit the API
# server — whose address is cluster-specific and not knowable in a template —
# plus DNS, plus arbitrary pod addresses and ports for endpoint confirmation,
# plus a backend later. That set is everything, and a rule permitting
# everything is a boundary in name only (ADR 0039).
#
# What bounds the egress is the code, and the bound is auditable: one package
# opens outbound connections, to one path, at addresses of pods your filters
# admitted, on ports their own processes were observed to bind.
policyTypes:
- Ingress
ingress:
Expand Down
9 changes: 8 additions & 1 deletion charts/runtime-agent/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"required": ["profile", "image", "filters", "spool", "nodeIntake", "profiling", "controller", "node"],
"properties": {
"profile": {
"description": "Which installation profile to install: metrics-only, inventory or ebpf. The accepted set is not repeated as an enum here — the template validates it, so one place decides and the refusal can explain itself (notably for `pprof`, which docs/security.md marks [planned]).",
"description": "Which installation profile to install: metrics-only, inventory or ebpf. The accepted set is not repeated as an enum here — the template validates it, so one place decides and the refusal can explain itself.",
"type": "string",
"minLength": 1
},
Expand Down Expand Up @@ -60,6 +60,13 @@
"additionalProperties": false,
"properties": {
"topN": {"type": "integer", "minimum": 0},
"pprof": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean"}
}
},
"allowedModulePrefixes": {
"description": "Required non-empty by profile `ebpf`; the chart refuses to render an empty allow-list with the profiler on.",
"type": "array",
Expand Down
18 changes: 15 additions & 3 deletions charts/runtime-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
# exactly CAP_BPF and CAP_PERFMON and a read-only /sys/kernel
# mount. Requires profiling.allowedModulePrefixes to be set.
#
# The `pprof` profile named in docs/security.md §3 is not installable: no puller
# exists yet, and a value the agent cannot honour must fail rather than look
# honoured (ADR 0025).
# There is no `pprof` profile. Pulling from `/debug/pprof` needs the two facts
# the node reads — a linked package and a bound port — so it is part of
# `inventory`, not an alternative to it (ADR 0057).
profile: metrics-only

image:
Expand Down Expand Up @@ -75,6 +75,18 @@ profiling:
# the agent's built-in default.
topN: 0

pprof:
# Whether the controller confirms which of your collected workloads serve a
# `/debug/pprof` endpoint. On, and it is the one collection control shipped
# that way (docs/security.md §10.3).
#
# What it costs the workload is one GET of the index page, once per image
# and port — no profiler is started and no connection is held. Which
# workloads are asked comes from your own filters; which ports, from what
# their processes were observed to bind. Inert without a node DaemonSet,
# which is where both of those facts are read.
enabled: true

# Node: the Go module-path prefixes of your own code whose stack frames may
# leave the node. Everything else is redacted before anything is sent.
#
Expand Down
36 changes: 35 additions & 1 deletion cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/RebuildStackCo/runtime-agent/internal/nodeauth"
"github.com/RebuildStackCo/runtime-agent/internal/nodeintake"
"github.com/RebuildStackCo/runtime-agent/internal/nodescan"
"github.com/RebuildStackCo/runtime-agent/internal/pprofprobe"
"github.com/RebuildStackCo/runtime-agent/internal/revisions"
"github.com/RebuildStackCo/runtime-agent/internal/rollup"
"github.com/RebuildStackCo/runtime-agent/internal/sink"
Expand Down Expand Up @@ -143,6 +144,12 @@ func connect() (kubernetes.Interface, *rest.Config, error) {
// anything to report: staleness here is a fact about the agent (ADR 0054 §5).
const coverageInterval = time.Minute

// pprofProbeInterval is how often endpoint discovery asks about targets it has
// no answer for. A round on a steady cluster does nothing — every target already
// has one — so the cadence costs a map walk and buys a new image being confirmed
// within a minute of the node reporting it (ADR 0057 §2).
const pprofProbeInterval = time.Minute

// run is the agent's lifecycle: it starts, works until ctx is canceled, and
// returns.
func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interface, restConfig *rest.Config, cfg config.Config, configShape config.Shape, startedAt time.Time) error {
Expand Down Expand Up @@ -330,6 +337,22 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
if cfg.NodeIntake.Enabled {
goStore = inventory.NewStore(time.Now())
}

// Endpoint discovery, which exists only where the node role does: the two
// facts it funnels on — the linked package and the bound port — are read on
// the node and arrive nowhere else (ADR 0057 §1).
var prober *pprofprobe.Prober
if goStore != nil && cfg.Profiling.Pprof.Enabled {
prober = pprofprobe.New(func(c pprofprobe.Candidate) (string, bool) {
ip, ok := podWatcher.PodAddress(c.Namespace,
collector.WorkloadRef{Kind: c.WorkloadKind, Name: c.WorkloadName},
c.Container, c.ImageDigest)
if !ok {
return "", false
}
return pprofprobe.HostPort(ip, c.Port), true
}, logger)
}
var inventoryMu sync.Mutex
flushInventory := func() {
if goStore == nil || spool == nil {
Expand Down Expand Up @@ -564,8 +587,13 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
Config: configShape,
UsageSignals: usagePoller.Signals(),
}
var probeCoverage *pprofprobe.Coverage
if prober != nil {
pc := prober.Snapshot()
probeCoverage = &pc
}
if err := spool.WriteCollectionCoverage(time.Now(), startedAt, agentInfo,
podWatcher.SourceHealths(), c, podWatcher.PlacementDrops(), inv, scan); err != nil {
podWatcher.SourceHealths(), c, podWatcher.PlacementDrops(), inv, scan, probeCoverage); err != nil {
logger.Error("spooling collection coverage", "error", err)
}
}
Expand All @@ -575,6 +603,12 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
ctx, cancel := context.WithCancel(ctx)
defer cancel()

if prober != nil {
go prober.Run(ctx, pprofProbeInterval, func() []pprofprobe.Candidate {
return pprofprobe.Candidates(goStore.PortSnapshot(), goStore.PprofDigests())
})
}

go func() {
ticker := time.NewTicker(coverageInterval)
defer ticker.Stop()
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0054-coverage-says-how-much-was-hidden-never-what.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Date: 2026-08-30

Status: Accepted

Amended by: 0057

Amends: 0012 §2, 0033

Adds the payload kind `collection_coverage` and a fifth provenance class,
Expand Down
138 changes: 138 additions & 0 deletions docs/adr/0057-the-controller-confirms-an-endpoint-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 0057. The controller confirms an endpoint once per build, and that is its first connection to a workload

Date: 2026-08-30

Status: Accepted

Amends: 0054

Adds `/debug/pprof` endpoint discovery to the controller: the first outbound
connection the agent makes to anything but the API server. Removes the `pprof`
installation profile, which never existed. No new RBAC, no new read of the API.

## Context

[ADR 0056](0056-a-pprof-endpoint-is-proved-not-probed.md) put two facts in the
controller's hands: which builds link `net/http/pprof`, and which ports their
processes bind. Neither says whether anything actually serves the handler. A
service may import the package for its side effect and never run a mux on
`DefaultServeMux`, and a binary that does serve it does so on one of its ports,
not all of them.

That last question is not answerable from the node. It is answerable by one
request, and the whole design is about making that request rare, bounded, and
about the right subject.

The other thing to settle is what `pprof` is. `security.md` §3 has listed it as
an installation profile since the document was written, and the chart has
refused to install it. Both were wrong about the shape of the thing:
`/debug/pprof` discovery needs the node's binary read and the node's socket
read, so it is part of `inventory`, not an alternative to it. It is a switch,
not a profile, and the profile is deleted the way ADR 0055 deleted
namespace-scoped installation.

## Decision

**1. The subject of a probe is a build's port, not a pod.** Every replica of an
image serves the same page, so asking a second one learns nothing. A
fifty-replica Deployment costs one request, and a cluster costs one request per
distinct (image digest, port) that survived the funnel.

Three stages run before any byte of network, and each can end the question for
free: the workload passed the collection filters, its build carries the marker,
and the port came from the process's own socket. A loopback port is dropped
rather than asked about — nothing outside the pod can open it, so the answer
would describe the agent's location rather than the workload.

**2. Two of the three answers are terminal, and the third is not.** A port that
served the index is confirmed; a port that answered with something else is
absent. Neither can change under a fixed digest, so neither is asked again — and
absence is the common case, since a workload that links the package serves it on
one port out of several.

A connection that failed is a fact about the network, not the endpoint: a
NetworkPolicy, a mesh, a pod mid-restart. It expires and is retried.

Confirmation matches the title Go's own index page carries, not the status code.
A 200 alone proves that something answered on the port, which is what a router
with a permissive fallback does.

**3. The pod IP is a connection parameter, not a fact.** The agent has held pod
addresses in its informer cache all along and has never read one; the cache
transform strips them from EndpointSlices precisely so they cannot be counted
(ADR 0046). This decision reads one, at the moment of connecting, from the
lister — and it enters no index, no record and no payload. `PodWatcher.PodAddress`
is the only place it is read, which is what makes that statement checkable.

**4. It is on by default, and that is the exception it looks like.** Every other
collection control in this agent defaults to the narrower setting. This one does
not, for a reason specific to what it costs: the request fetches a page rendered
from memory, starts no profiler, holds no connection, and asks once per image.
There is no meaningful sense in which the workload pays for it.

The argument that would keep it off is that an upgrade must not silently change
behaviour. That argument is sound in general and empty here — the transport and
identity of ADR 0008 are unbuilt, so there is no installation shipping anything
to a backend to surprise. By the time there is, this will have been the default
from the beginning.

What the default costs instead is a documentation obligation, and it is
discharged in the same pull request: `security.md` §10.3 stops describing a
plan and describes what the agent does, before anyone installs it. A customer
who learns this from the document has agreed to it; one who learns it from an
unexpected connection has not.

**5. There is no egress NetworkPolicy, and that is a decision rather than an
omission.** The existing policy's comment deferred egress restriction to "the
change that gives it something to egress to". This is that change, and the
answer is no.

An egress rule would have to permit the API server — a ClusterIP that is not
knowable when the chart renders — plus DNS, plus arbitrary pod addresses on
arbitrary ports, plus a backend later. That set is everything, and a rule
permitting everything is a boundary in name only. Writing it would repeat the
mistake ADR 0039 was written about: a control that reads as a limit and is not
one.

What bounds the egress is the code, and the bound is the kind that can be
checked: one package opens outbound connections, to one path, at addresses of
pods the filters admitted, on ports the processes were observed to bind. This
is the same form the `nodes/proxy` disclosure takes in `security.md` §5 — the
grant is wide, the call sites are counted.

**6. The path is an allow-list of one, and the reason is not caution.** The
pprof mux serves `/debug/pprof/cmdline`, which returns the process's own argv —
the field CLAUDE.md invariant 4 drops at the source, in the informer cache
transform, on the node, and everywhere else. An endpoint that hands it over on
request is not a reason to collect it. `TestOnlyTheIndexPathIsRequested` asserts
that the only path requested is the index.

## Consequences

**Easier.** "Nine of your services can be profiled right now, and three of them
expose a debug handler on a routable address" becomes sayable before anything
is pulled. The first of those is the input to a puller; the second is a security
finding that needs no puller at all.

The counters land in `collection_coverage` (ADR 0054), so "no profiles for this
workload" separates into three causes: never confirmed, confirmed-absent, or
unreachable. Without them the absence would be the indistinguishable silence
that payload exists to end.

**Harder / given up.** The agent now connects to customer workloads. That is a
real change in what it is, however small each connection is, and it is the
reason §10.3 and the values file both had to be rewritten rather than amended.

A confirmed endpoint is not a profile. The request that starts a profiler in the
customer's process is a separate decision with its own ADR, its own default, and
a different risk — this one holds nothing open and changes nothing.

Discovery is inert without a node DaemonSet. Both facts it funnels on are read
under `/proc`, so a `metrics-only` installation finds nothing, and the chart
renders the switch off there rather than leaving it to look enabled.

**Not changed.** No RBAC, no new API read, no capability. The node role still
holds no Kubernetes identity, and the `create` verb still appears nowhere.

This ADR records decisions implemented in the same pull request, per the process
in [`README.md`](README.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,4 @@ line to paste.
- [0054. Coverage says how much was hidden, never what](0054-coverage-says-how-much-was-hidden-never-what.md)
- [0055. The installation is cluster-wide, and there is no narrower mode](0055-the-installation-is-cluster-wide.md)
- [0056. Whether a pprof endpoint exists is proved on the node, not probed over the network](0056-a-pprof-endpoint-is-proved-not-probed.md)
- [0057. The controller confirms an endpoint once per build, and that is its first connection to a workload](0057-the-controller-confirms-an-endpoint-once.md)
12 changes: 12 additions & 0 deletions docs/backend-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,18 @@ a record is absent, never zero, when none were read. The record is keyed by
image digest as well as container: a peak belongs to the build that reached it,
and MUST NOT be carried across a rollout.

**`collection_coverage.pprof` counts endpoint targets, not workloads, and the
block is absent when discovery is off**
([ADR 0057](adr/0057-the-controller-confirms-an-endpoint-once.md)). A target is
one (image digest, port), so a workload with several bound ports contributes
several. `confirmed` served the pprof index; `absent` answered and did not,
which for a build whose `pprof_endpoint` is true means that port is not the one
serving it — the backend MUST NOT read `absent` as the package being missing.
`unreachable` is a statement about the network, not the endpoint, and is
retried, so it MUST NOT be presented as a workload that cannot be profiled. An
absent block means discovery is switched off or no node role is deployed; it
does not mean zero.

**`listening_ports` is what the processes bind, and `go_build.pprof_endpoint` is
what the build contains; neither implies the other**
([ADR 0056](adr/0056-a-pprof-endpoint-is-proved-not-probed.md)). The flag says
Expand Down
4 changes: 2 additions & 2 deletions docs/prose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ var ceilings = []struct {
lines int
section int
}{
{"security.md", 980, 325},
{"backend-requirements.md", 560, 420},
{"security.md", 996, 330},
{"backend-requirements.md", 575, 435},
}

func TestNoCommentRunIsLongerThanAPointerToAnADR(t *testing.T) {
Expand Down
Loading