Deploying agents & collectors
probectl's control plane never goes out and measures the network itself. It consumes. The measuring is done by a small family of agents and collectors that you deploy out where the truth lives — on probe hosts, next to your routers, on the switches' management network, inside the kernel of your servers, and on end-user laptops. Each one is a single static Go binary; each one sends what it sees back to the control plane; each one is bound to exactly one tenant so its data can never land in another tenant's view.
This page is the index: what producers exist, and how do I deploy each one? Pick the producers for the planes you actually care about — you do not need all of them. If you just want to see any data flow end-to-end first, start with the hands-on getting-started guide (it walks you from zero to a first probe result), then come back here to add the rest.
The deep "how each plane works" explanations live in the per-plane docs linked from each section below. This page stays at the "which binary, where does it run, how does it ship data, what's the one command" level on purpose.
The two ways a producer ships its data
Before the catalog, the single most important distinction — because it decides what backend infrastructure each producer needs. Think courier versus postal system: one producer hands its parcel directly to the recipient (nothing else has to exist), the others drop parcels at a depot the recipient empties on its own schedule — which means the depot has to be standing.
- Streams to the control plane over gRPC/mTLS. The agent holds an authenticated, tenant-bound connection straight to the control plane (gRPC is the HTTP/2-based remote-procedure-call protocol the stream rides on; mTLS is mutual TLS — both ends present certificates, so the server proves itself to the agent and the agent proves itself to the server). It needs nothing but the control plane reachable. This is the canary/synthetic agent (and, optionally in future, roaming endpoints). This is the courier.
- Publishes to the message bus. The collector writes records onto a bus topic
(
probectl.<type>.results/probectl.<type>.events), and a control-plane consumer drains the topic into storage. In a real fleet that bus is Kafka (the open-source distributed message log: producers append, consumers read at their own pace, and the log absorbs bursts neither side could handle alone), and the high-cardinality planes land in ClickHouse (the column store built for billions of small event rows) — so these producers imply "you are running Kafka + ClickHouse" (or the lightweight in-memory bus, for a single-node dev box only). This is the BGP, flow, device, eBPF, and endpoint producers — the postal model, and the depot is Kafka.
So: a synthetic-only deployment can run with just the control plane and its Postgres. The moment you want BGP router feeds, flow, device telemetry, eBPF, or endpoint data at fleet scale, you are also standing up Kafka and ClickHouse. Each section below says which camp its producer is in.
Enroll once: how an agent gets its identity
Every agent that talks to the control plane proves who it is with a short-lived
mTLS client certificate — an SVID — whose SPIFFE identity names its tenant
and agent id (spiffe://probectl/tenant/<t>/agent/<a>; SPIFFE is the standard
URI naming scheme for workload identities, and an SVID is the certificate that
carries one). Until an agent has one,
the mTLS transport refuses its connection and nothing it sends lands anywhere.
You do not hand-distribute certificates; the trust root is managed by the
control plane, and the runtime rotates the certificate automatically forever
after.
The lifecycle is: set up the CA once per deployment, mint a one-time join token per agent, and redeem it on the agent host.
1. One-time, on the control host — create the certificate hierarchy:
probectl-control agent-ca init
probectl-control agent-ca export /etc/probectl/agent-ca.crt
init prints the root private key once for offline custody and never stores
it; runtime operation never needs it. It prints only to a terminal: run through
a Job, a Helm hook or a CI step — anywhere stdout is a pipe or a log — and it
refuses, because those bytes would be stored wherever that output goes
(DPR-121). Automate it with agent-ca init -key-out /path/key.pem (written
0600, move it to custody and delete the file), or -print-key when the
destination really is safe custody, such as a pipe into your vault. Re-running refuses to overwrite the trust
root. export writes the CA's public trust bundle (root + intermediate
certificates — never a key) to a file; point the control plane's
PROBECTL_AGENT_TLS_CA_FILE at it so the gRPC listener can verify enrolling
agents. (Helm: kubectl exec deploy/<release> -- /usr/local/bin/app agent-ca init, then --set control.agentListener.enabled=true; the chart's init
container runs the export for you and the listener is the <release>-agents
Service — Helm README.)
2. Mint a join token (one per agent; the token names the tenant):
From the web UI, go to Admin & Settings → Agents → Enroll agent. The modal
mints the tenant-scoped token, shows it once, and prints the
probectl-agent enroll command with either the current server certificate pin
or the --ca-file trust path.
probectl-control enroll-token -tenant <tenant-uuid> [-agent <id>] [-name "edge-probe-1"] [-ttl 1h]
The CLI path prints the same token (pjt_…) once; it is single-use,
expires (default 1h), and is tenant-scoped. The command also prints the control
plane's certificate pin for first contact when PROBECTL_TLS_CERT_FILE is
configured.
3. Redeem it on the agent host:
probectl-agent enroll \
--server https://<control-host>:8443 \
--token pjt_... \
--dir /var/lib/probectl-agent/identity \
--ca-pin <hex-sha256> # for self-signed quickstarts
# ...or, with a CA-issued control-plane cert: --ca-file ca.crt
In the shipped container images the agent runs as the distroless non-root user,
and only /var/lib/probectl and /var/lib/probectl-agent are pre-created for
it — mount the identity volume under one of those (--dir /var/lib/probectl-agent/identity); any other fresh volume is root-owned and
the enrollment refuses it before touching the token.
The agent generates its private key locally (it never leaves the host), sends
a CSR — a certificate signing request: the public half of the keypair, packaged
for the CA to sign — and writes the issued cert, the intermediate, and the
trust bundle (mode
0600) into --dir. A --ca-pin that mismatches refuses the connection —
there is no trust-on-first-use fallback. enroll then prints the exact tls: /
identity: config snippet to paste into the agent's config so it rotates
automatically.
For fleets where a separate enroll step is awkward (containers, cloud-init),
the canary agent also enrolls itself on first boot: hand it the token via
the PROBECTL_AGENT_JOIN_TOKEN environment variable or the enroll.token_file
config key, and it redeems the token before starting. This is idempotent — an
agent that already has an identity skips it — and fail-closed: a consumed or
invalid token stops the agent with a clear error instead of running
unauthenticated.
For SVID rotation (every ~24h, automatic), revocation, and the full bootstrap threat model, see agent enrollment & rotation.
Register bus collectors
BGP, flow, device, eBPF, and endpoint collectors publish to the bus instead of
streaming over the mTLS agent channel, so they do not redeem an SVID. They still
need a tenant-bound registry identity: every published record carries
tenant_id and agent_id, and the control plane checks that pair against the
tenant's registry before accepting the batch.
From the web UI, go to Admin & Settings > Agents > Register collector. Pick
the plane, enter an optional source label, and optionally pin a collector UUID.
The UI mints a short-lived one-time token for the current tenant, immediately
consumes it through /v1/collectors/register, and shows the env/YAML keys to
put in the collector config. The browser never sends tenant_id; the server
derives it from the authenticated tenant before consuming the token.
CLI/API equivalent:
# 1. Mint a tenant-scoped one-time token.
probectl agent enroll-token --body '{"name":"edge-flow-1","ttl_seconds":300}'
# 2. Register the collector with that token.
probectl collector register --body '{"token":"pjt_...","plane":"flow","hostname":"edge-flow-1"}'
probectl bgp setup --body '{"token":"pjt_...","plane":"bgp","hostname":"rrc00"}'
Registering a second collector under a name the tenant already uses answers
409 conflict naming the collision: reuse the existing agent_id or pick
another name (DPR-048).
The response includes tenant_id, agent_id, capabilities, and concrete
config hints — including the tenant's bus lane
(PROBECTL_<PLANE>_BUS_NAMESPACE=t-<tenant-slug>). Every active tenant, pooled
or siloed, owns a namespaced lane; in the multi-tenant and regulated profiles
the shared lane is refused for agent-published planes
(PROBECTL_INGEST_STRICT_TENANT_LANES), so the collector must carry that
setting or every batch is rejected at the control plane (DPR-049). Flow,
device, endpoint and eBPF all read the identity as agent_id
(PROBECTL_<PLANE>_AGENT_ID) and the lane as bus.namespace
(PROBECTL_<PLANE>_BUS_NAMESPACE); the YAML hints are the collectors' real
keys, nested where the collector nests them, so they paste straight into the
config file (DPR-051/DPR-053). BGP returns BMP listener hints, including
source_type: bmp, PROBECTL_BMP_COLLECTOR=<collector-id>, and the startup
command probectl-bmp-listener.
Bus topics
The control plane creates the topics it publishes and consumes
(PROBECTL_BUS_CREATE_TOPICS=true, the default; DPR-047): the shared lanes at
startup and a siloed tenant's namespaced lanes as that tenant appears. If your
broker user may not create topics, set it to false and pre-create these —
startup then verifies them and fails closed naming any that is missing:
| Topic | Producer → consumer |
|---|---|
probectl.network.results |
canary/synthetic agents (via the gRPC listener) → result pipeline |
probectl.endpoint.results |
probectl-endpoint → result pipeline |
probectl.rum.events |
RUM beacons → result pipeline |
probectl.flow.events |
probectl-flow-agent → flow consumer |
probectl.device.metrics |
probectl-device-agent → device consumer |
probectl.bgp.events |
BMP listener / analyzer → BGP consumer |
probectl.otlp.metrics |
OTLP ingest → metrics consumer |
probectl.deadletter.results |
consumers → replay tooling |
probectl.<namespace>.<lane> |
the same lanes, one namespace (t-<slug>) per active tenant — pooled or siloed; the lanes agent-published planes must use in strict-lane mode |
Partitions and replication for created topics: PROBECTL_BUS_TOPIC_PARTITIONS
(3) and PROBECTL_BUS_TOPIC_REPLICATION (-1 = the broker default).
Startup reads the brokers' topic metadata before it creates anything. If that
read fails, startup refuses with "cannot read topic metadata from the brokers"
and the cause, for example x509: certificate signed by unknown authority.
That is a reachability, TLS or SASL fault, not a topic permission: check
PROBECTL_BUS_BROKERS, PROBECTL_BUS_TLS_CA_FILE and the
PROBECTL_BUS_SASL_* settings. Only a create the broker refuses asks you to
grant the bus user CREATE (DPR-335).
The producers
Canary / synthetic — probectl-agent
What it observes. Active, on-purpose probes you schedule: it sends traffic
and times the answer. Compiled-in probe types are icmp (loss / latency /
jitter — jitter being the variation between round-trips, what voice and video
feel), tcp (connect latency + reachability), udp (echo round-trip),
dns (resolution time, answer, DNSSEC validation, plus iterative
delegation traces — walking root → TLD → authority itself instead of trusting a
resolver's cache),
http (availability + DNS/connect/TLS/TTFB/total breakdown — TTFB is
time-to-first-byte — and on HTTPS the
TLS handshake details that feed the TLS-posture view), browser (either a
non-rendering HTTP transaction or a rendered Playwright transaction, selected
explicitly by both test and agent), and voice (RTP media probes scored as MOS — the 1–5
mean-opinion-score call-quality scale — plus jitter / loss) — plus a noop
heartbeat. It can also do agent-to-agent
(a2a) two-way measurement (TWAMP-lite style: both ends timestamp, so each
direction's latency is measured separately) when you enable the a2a: block,
turning a pair of agents into a synthetic mesh. A2A is opt-in (enabled: false
by default), and every brokered probe/reply frame is authenticated with a
per-session HMAC key delivered over the existing agent mTLS channel. Unlike the
single-agent canaries above, A2A is not placed in canaries: and does not run on
a local interval: an audited API/CLI request creates the pair or mesh, and the
control-plane broker assigns each agent its role. See the
A2A coordination ADR.
Where it runs. Any OS, unprivileged by default — ICMP uses unprivileged
datagram sockets, so no CAP_NET_RAW and no root for the common case.
Rendered browser probes use the separate non-root probectl-browser-agent
container because Chromium and its OS libraries are intentionally not embedded
in the portable Go binary.
How it ships data. It streams straight to the control plane over
gRPC/mTLS — it does not use the bus. Point it at the control plane's gRPC
listener via control_plane.grpc_addr (required — there is no built-in default;
the shipped examples and stacks use port 9443), using the mTLS identity from
enrollment. Results buffer to a local disk queue while the control
plane is unreachable and drain on reconnect, so a network blip never loses data.
Infra it needs: just the control plane (no Kafka, no ClickHouse).
Deploy it.
probectl-agent -config /etc/probectl/agent.yml
Config template: deploy/agent/probectl-agent.example.yml.
Every key (and the per-probe parameters) is documented in
configuration.md; voice specifics are in
voice.md, and the browser/transaction synthetic in
browser-synthetic.md.
Flow collector — probectl-flow-agent
What it observes. The passive, after-the-fact view of real traffic: routers and switches summarize the packets they forwarded into flow records (the 5-tuple — source/destination address, source/destination port, protocol — plus byte/packet counts) and export them. The collector decodes NetFlow v5/v9, IPFIX, and sFlow v5 — three export dialects of that same idea — into one normalized, tenant-bound record.
Where it runs. As a service on a host on (or adjacent to) your management
network. Flow export is plaintext UDP with no authentication — by protocol
design — so deploy the collector next to its exporters and never let those
datagrams cross an untrusted segment. It binds three UDP listeners: :2055
(NetFlow v5 and v9, version-sniffed on one socket), :4739 (IPFIX), :6343
(sFlow). Disable any listener you don't run.
How it ships data. It publishes to the bus on probectl.flow.events; the
control plane's flow consumer verifies the tenant, optionally enriches ASN/geo
(ASN — the autonomous-system number, i.e. which network on the internet owns
the address — and its geolocation),
and writes rows into ClickHouse. Infra it needs: the message bus
(bus.mode: kafka → Kafka) and ClickHouse. A single-node dev box can set
bus.mode: memory instead.
Deploy it.
probectl-flow-agent -config /etc/probectl/flow-agent.yml
Config template: deploy/agent/probectl-flow-agent.example.yml.
Full plane walkthrough, the OTel field mapping, sampling correction, and the
query API: flow analytics.
Device / streaming telemetry — probectl-device-agent
What it observes. The health of the switches and routers themselves —
interface counters and oper-status, CPU, memory, and (opt-in) temperatures —
straight from the gear. It speaks two protocols and normalizes both into one
DeviceMetric with identical metric names: SNMP (the Simple Network
Management Protocol, v2c or v3 — the decades-old standard where the agent
polls on an interval) and gNMI / OpenConfig (the gRPC Network Management
Interface — the modern alternative where the device streams updates
over a TLS gRPC channel). Same metric names either way is the point: dashboards
and alerts don't care which protocol a given switch happened to speak.
Where it runs. As a service with line-of-sight to the devices' management
addresses. gNMI dials TLS with certificate verification on by default (system
roots, or pin a private CA with ca_file); there is no skip-verify, and the
lab-only plaintext opt-in is loudly logged. Credentials (SNMP communities,
SNMPv3 passphrases, gNMI passwords) are referenced by name only and resolved
at runtime — never written to config or git, never logged. A credential that
resolves to nothing fails closed at startup.
How it ships data. It publishes to the bus on probectl.device.metrics;
the control plane's device consumer lands the series in the time-series
database. Infra it needs: the message bus (bus.mode: kafka → Kafka) (plus
your TSDB, which the control plane already owns). Single-node dev: bus.mode: memory.
Deploy it.
probectl-device-agent -config /etc/probectl/device-agent.yml
Config template: deploy/agent/probectl-device-agent.example.yml.
Metric catalog, the SNMP↔gNMI unification, FIPS notes, and how device interfaces
correlate path and flow data: device telemetry.
eBPF host agent — probectl-ebpf-agent
What it observes. Network activity from inside the host's kernel, with zero instrumentation — no sidecars, no app changes, no SDK. (eBPF lets the kernel run small, sandboxed, pre-verified observation programs — the agent attaches them to the kernel's own networking events instead of capturing packets.) It captures every TCP connection a host makes or accepts (the L3/L4 flow — addresses and ports — plus the process and container behind it) and builds a live service map of who-talks-to-whom. It can additionally parse L7 (application-layer) calls (HTTP/1.1+2, gRPC, DNS, Kafka), including over TLS via library uprobes — kernel hooks attached to a user-space library's functions, here the TLS library, so data is visible before it is encrypted — and reading application plaintext is off by default and triple-gated (an enable flag plus a per-tenant consent that must name this agent's tenant plus a non-empty workload allowlist; host-wide capture is not expressible). It is observe-only — it loads no enforcing program and blocks no packet, a guarantee enforced by a build-failing test. It is not a CNI and not an inline IPS.
Where it runs. Linux only, on each host you want to see. It needs
CAP_BPF + CAP_PERFMON on supported kernels — the two Linux
capabilities that permit loading BPF programs and attaching to perf events,
granted without root — and a
BTF-exposing kernel (/sys/kernel/btf/vmlinux, mainstream from 5.8 — BTF
is the kernel's embedded type catalog, which lets one compiled agent adapt to
any kernel). The L4 flow plane needs no tracefs and no root: it attaches as a
raw tracepoint, and the kernel-matrix job runs it as a non-root user with only
that capability pair (ING-23). The Helm chart mounts tracefs only
when l7Capture.enabled, for the L7 TLS uprobe path (DPR-345). Generic kernels older than 5.8 are unsupported by default;
CAP_SYS_ADMIN is only an explicit legacy break-glass when the runtime probe
confirms BTF plus BPF ring-buffer support but split caps cannot be granted. On
macOS/Windows, run it inside a Linux VM. The shipped image is the live build;
fixture-replay mode exists only for CI / no-kernel boxes.
How it ships data. It publishes to the bus on probectl.ebpf.flows. The
shipped artifacts default to bus.mode: kafka. Infra it needs: Kafka.
Control-plane consumers (topology, segmentation, NDR) read these flows live;
raw flow-by-flow retention in ClickHouse is not wired yet, so there is no
queryable per-flow history — only what the consumers derive. That retention edge
is tracked in the canonical
limitations table. The agent
refuses plaintext Kafka unless you explicitly opt in for a lab.
Deploy it (VM / bare metal).
sudo deploy/agent/install.sh ./bin/probectl-ebpf-agent
$EDITOR /etc/probectl/ebpf-agent.yaml # set tenant_id + bus brokers
sudo systemctl start probectl-ebpf-agent
The installer is air-gap friendly (it downloads nothing and never self-updates),
creates a dedicated non-root probectl-agent system user, and installs the
hardened systemd unit (ambient CAP_BPF+CAP_PERFMON, a default-deny
syscall filter, namespace lockdown) shipped alongside it. The unit and its
matching container seccomp profile (seccomp — the Linux syscall filter; the
profile is the allowlist of kernel calls the process may make) live in
deploy/agent/ (probectl-ebpf-agent.service,
seccomp.json). Config template:
deploy/agent/probectl-ebpf-agent.example.yml.
The full plane — capture pipeline, the triple-gate, redaction modes, the
kernel/uprobe compatibility matrix, and the build: eBPF host agent.
Removing it. Uninstalling leaves the policy channel's node identity
directory (its private key and last certificate, usable until it expires,
at most 24 h) on each node: revoke the collector
(POST /v1/agents/{id}/revoke), then delete the directory — see
eBPF host agent ("Uninstalling leaves the node identity
behind").
Endpoint / last-mile DEM — probectl-endpoint
What it observes. The last mile that server-side canaries physically cannot see: a remote user's Wi-Fi link health, their local gateway, the ISP / last-mile path, and browser-session timings — and then the headline trick, it attributes a slowdown to the closest impaired layer (Wi-Fi → local LAN → ISP → wider network), so you can answer "is it us, or the user's Wi-Fi / ISP?". It runs on the user's own device, so data minimization is a hard rule (geolocatable identifiers — like the AP MAC, the Wi-Fi access point's hardware address, enough to place a household on a map — and public hop IPs are dropped before a sample is ever emitted), and it discloses exactly what it collects at startup.
Where it runs. Linux in the beta, on end-user devices (macOS and Windows
builds compile but are not yet supported),
no elevated privileges — it uses the OS's own traceroute/tracert and
read-only Wi-Fi queries. Ship the single static binary via your MDM (mobile
device management — the tool IT already uses to push software to managed
laptops: Intune, Jamf).
How it ships data. It publishes to the bus on
probectl.endpoint.results, tenant-keyed, flowing through the same pipeline as
every other canary; it never phones home. Infra it needs: the message bus
— Kafka in a fleet, or durable lightweight NATS JetStream. Register its
collector identity and use the returned tenant bus namespace as described
above. memory is process-local and cannot deliver from this standalone agent
to the control plane, even on the same host. See endpoint DEM
for the NATS configuration.
Deploy it.
probectl-endpoint -config /etc/probectl/endpoint.yml
The privacy model, the attribution engine, the per-OS collection matrix, and the
/v1/endpoints fleet surface: endpoint DEM.
A producer-by-producer cheat sheet
| Producer | Binary | Runs on | Ships via | Backend it implies | Deploy |
|---|---|---|---|---|---|
| Canary / synthetic | probectl-agent |
any OS, unprivileged | gRPC/mTLS → control plane (:9443) |
control plane only | probectl-agent -config agent.yml |
| Flow | probectl-flow-agent |
mgmt network (UDP :2055/:4739/:6343) |
bus (probectl.flow.events) |
Kafka + ClickHouse | probectl-flow-agent -config flow-agent.yml |
| Device | probectl-device-agent |
reaches device mgmt | bus (probectl.device.metrics) |
Kafka (+ TSDB) | probectl-device-agent -config device-agent.yml |
| eBPF host | probectl-ebpf-agent |
Linux, CAP_BPF+CAP_PERFMON, BTF ≥5.8 |
bus (probectl.ebpf.flows) |
Kafka (+ ClickHouse) | sudo deploy/agent/install.sh ./probectl-ebpf-agent (cosign verification is default) |
| Endpoint / DEM | probectl-endpoint |
Linux (beta), unprivileged | bus (probectl.endpoint.results) |
Kafka or NATS JetStream | probectl-endpoint -config endpoint.yml |
At scale
Running one agent by hand is the learning path. A fleet is run by your orchestrator — probectl deliberately has no agent self-update channel (that would be a fleet-wide remote-code-execution primitive), so update authority stays with your tooling.
Kubernetes — the eBPF host agent as a DaemonSet (Kubernetes's run-one-copy-on-every-node primitive — exactly the shape a per-host kernel observer needs). The supported chart is
deploy/helm/probectl-agent. It declares the privilege contract in the manifest (drop all capabilities, add back onlyCAP_BPF/CAP_PERFMON, a seccomp profile, read-only root, the BTF and tracefs host mounts, resource limits) and renders fail-closed — notenantID, or plaintext Kafka without an explicit opt-in, refuses to template. It also renders the Kyverno image-integrity policy by default so admission requires the digest and the keyless cosign signature from the taggedrelease.ymlworkflow before the privileged pod can start — and the install is two steps, policy first, so the very first pod is verified too (Image-integrity admission below). Disabling that policy requires an explicitadmission.imageIntegrity.acceptedRisknote.cat > agent-values.yaml <<'EOF' tenantID: <tenant> agentID: <collector-id> bus: namespace: t-<tenant-slug> brokers: ["kafka.probectl.svc:9093"] tls: {existingSecret: probectl-bus-tls} sasl: {mechanism: scram-sha-512, existingSecret: probectl-bus-sasl} image: tag: "0.6.6@sha256:<digest>" EOF # 1. the image-integrity policy, alone; wait until Kyverno enforces it helm template probectl-agent deploy/helm/probectl-agent -n probectl -f agent-values.yaml \ --show-only templates/image-integrity-policy.yaml \ | kubectl apply --server-side --force-conflicts --field-manager=helm -f - kubectl wait --for=jsonpath='{.status.conditionStatus.ready}'=true \ imagevalidatingpolicy/probectl-agent-image-integrity --timeout=2m # 2. the agent (the release adopts the policy from step 1) helm install probectl-agent deploy/helm/probectl-agent -n probectl -f agent-values.yamlagentIDis the collector identity the tenant registered for these nodes (previous section) andbus.namespacethe lane the registration printed; the chart refuses to render without the identity, because the control plane would reject every batch (DPR-051). Bus client auth is a Secret:bus.sasl.*(keysusername/passwordby default) orbus.tls.clientAuth=trueto presenttls.crt/tls.keyfrombus.tls.existingSecret.Naming heads-up: the chart is named
probectl-agentbut it deploys the eBPF host agent (probectl-ebpf-agentimage) — it is the per-node flow agent, not the syntheticprobectl-agentbinary. The eBPF agent is the one with a shipped Helm chart, systemd unit, and installer; the canary, flow, and device agents ship example configs and run as ordinary services or containers in your platform.VMs / bare metal — the installer.
deploy/agent/install.shinstalls a local binary (air-gap friendly: downloads nothing, never self-updates), creates the non-root system user, installs the hardened systemd unit, and writes a fail-closed sample config. Re-run it with a new binary to update.Staged fleet rollout. Move a fleet to a new version in waves (canary → early → main) from signed artifacts, with the agent registry verifying each wave and any straggler halting the train — so a bad version stops at a small canary instead of taking out the whole fleet. The model, the operator flow, and halt/resume semantics are in staged fleet rollout.
Image-integrity admission (Kubernetes)
The eBPF agent is privileged, so the agent chart does not stop at pinning a
digest: it renders a Kyverno ImageValidatingPolicy,
probectl-agent-image-integrity, that checks the image again at admission time,
in the cluster, every time a pod is created (RTO-12). Kyverno 1.17 or later must
already be installed: the policy is policies.kyverno.io/v1, which Kyverno
serves from 1.17, and Kyverno 1.19 deprecates the kyverno.io/v1
ClusterPolicy (verifyImages) form the chart used to render. Against an
older Kyverno the chart refuses to install rather than render a kind nothing
enforces. In ELI5 terms: the chart writes down which photo is allowed in, and a
bouncer (Kyverno) checks every pod's photo against it at the door.
What it enforces. For every Pod in every namespace Kyverno watches (and,
through Kyverno's autogen rules, every DaemonSet, Deployment, Job, … template)
whose container, initContainer or ephemeral-container image matches
<image.repository>*, or one of
admission.imageIntegrity.additionalImageReferences (default
*/ctlplne/probectl-ebpf-agent*) — each pattern compared with the raw image
string and with its Docker-normalized form (ctlplne/x is
docker.io/ctlplne/x), as the ClusterPolicy did:
- the reference must carry a digest — a tag-only reference is refused
(
does not have a digest); - that digest must carry a cosign signature from the configured attestor —
unsigned, or signed by any other key or identity, is refused
(
image signature not verified by the configured attestor, naming each image; Kyverno'sImageValidatingPolicydoes not tell the two apart in its answer). The default attestor is keyless: the taggedctlplne/probectlrelease.ymlworkflow's GitHub OIDC identity, verified through the Rekor transparency log; admission.imageIntegrity.failureAction: EnforcerendersvalidationActions: [Deny].Auditreports without blocking and is accepted only with anadmission.imageIntegrity.acceptedRisknote;failurePolicy: Fail: a Kyverno that cannot answer (hung, crashed, partitioned) refuses the pod — even a correctly signed one.
Why two steps. Helm creates object kinds it does not know (an
ImageValidatingPolicy) after the DaemonSet and never waits for Kyverno to
load a policy, so a one-step install admits the first agent pods before their
verifier exists. Helm hooks are not the fix: Helm deletes and re-creates a hook
object on every upgrade — a window with no enforcing policy — and does not wait
for a hook policy to become Ready either. So the policy is applied first (step 1
above), and the chart refuses to install or upgrade (readinessGate: lookup, the default) unless the exact policy that release renders — matched by
its probectl.dev/policy-sha256 annotation — is already in the cluster,
Kyverno reports it ready (status.conditionStatus.ready and its
WebhookConfigured condition), and Kyverno's validating resource webhook
serves that policy (/ivpol/validate/<policy>) with failurePolicy: Fail. Step 1 is idempotent: re-run it before any upgrade that
changes image.repository or the attestor (the chart tells you when it has to).
A plain image-version upgrade does not change the policy.
Upgrading from a chart that rendered the ClusterPolicy. Run step 1 as
usual: it creates the ImageValidatingPolicy beside the old ClusterPolicy
(different kinds, same name), so both verify until helm upgrade removes the
ClusterPolicy the release no longer renders. There is no moment with neither.
A mirrored or air-gapped registry. The verified reference is derived from
image.repository, so pointing the chart at your mirror also points the policy
at it — a mirror can no longer bypass the check by having a different path.
image.repository must name its registry host explicitly
(registry.corp.example:5443/mirror/probectl-ebpf-agent, not
mirror/probectl-ebpf-agent), because the reference a bare path normalizes to
is docker.io/…, which the derived pattern would never match. If the mirror keeps the
upstream signatures (copy them with cosign copy), nothing else changes.
If it cannot reach Rekor, or re-signs what it mirrors after verifying upstream,
give the chart the mirror's public key instead of the keyless identity:
image:
repository: registry.corp.example:5443/mirror/probectl-ebpf-agent
tag: "0.6.6@sha256:<digest>"
admission:
imageIntegrity:
publicKey: | # or: --set-file admission.imageIntegrity.publicKey=mirror-cosign.pub
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
ignoreTlog: true # only for a signer that cannot upload to a transparency log
GitOps. Argo CD renders offline but advertises the cluster's API versions,
so the lookup gate cannot see the live policy there: set
admission.imageIntegrity.readinessGate=external and name the mechanism in
externalOrdering. The policy carries argocd.argoproj.io/sync-wave: "-1",
and Argo CD needs a health check for the policy kind, or it calls the policy
Healthy the moment it exists and the DaemonSet's wave goes ahead.
deploy/gitops/argocd/argocd-cm-kyverno-health.yaml
ships one for policies.kyverno.io/ImageValidatingPolicy (and the
capability-posture ValidatingPolicy). It is Healthy only when Kyverno
reports status.conditionStatus.ready: true and a WebhookConfigured
condition that is True, meaning Kyverno has registered the policy in its
admission webhook. Anything else is Progressing, so the wave waits. Merge it
into argocd-cm:
kubectl -n argocd patch configmap argocd-cm --type merge \
--patch-file deploy/gitops/argocd/argocd-cm-kyverno-health.yaml
With the argo-cd Helm chart, put the same two keys under configs.cm. A health
check sees only its own object, so it cannot notice Kyverno being scaled to
zero later: the status keeps its last value. The admission-check CronJob below
covers that. Flux's helm-controller performs
a real install, so the default gate works once the policy is applied first
(a separate Kustomization the HelmRelease dependsOn, with a health check).
Know your Kyverno. Kyverno removes its own webhooks when its admission
controller is scaled to zero; from then on nothing in the cluster is verified,
while the policy still reads Ready. The chart's gate refuses to roll out in
that state, and between rollouts the chart watches for it too:
The admission-check CronJob (
admission.imageIntegrity.webhookCheck, on by default; every 5 minutes) runsprobectl-ebpf-agent admission-checkfrom the agent image itself — the same signed, digest-pinned image, so the policy verifies it and no other image is pulled. It reads theImageValidatingPolicyand Kyverno'skyverno-resource-validating-webhook-cfg, and fails when the policy is missing, is not the one this release renders (policy-sha256), is not ready, or nofailurePolicy: Failwebhook serves it. A failure leaves a Failed Job (the last three are kept) and oneERRORlog line naming every problem:kubectl -n probectl get jobs -l app.kubernetes.io/component=admission-check kubectl -n probectl logs job/<failed-job>Alert on it with kube-state-metrics:
kube_job_status_failed{namespace="probectl", job_name=~"probectl-agent-probectl-agent-admission-check-.*"} > 0. The check is observe-only: its ClusterRole isgeton exactly those two objects, it uses a 10-minute projected token (no automounted one), and it never repairs anything — restoring Kyverno stays a human decision. An unreadable state (API error, RBAC removed) fails the Job too.Run Kyverno with several replicas, so a single eviction does not remove the webhooks in the first place.
Kyverno accepts the shipped policies (ImageValidatingPolicy,
ValidatingPolicy) without a deprecation warning.
Proof. scripts/kyverno_admission_smoke.sh (CI job kyverno-admission)
runs all of this on a real kind cluster with a pinned Kyverno: Kyverno accepts
the shipped policies with no deprecation warning; the chart refuses to install before its policy exists;
the two-step install is admitted; an unsigned digest, a digest signed by another
key, a tag-only reference, an unsigned pod outside the chart, and an unsigned
image in a mirror with a different path are each denied with the policy's
message; the signed mirror image is admitted; a Kyverno that cannot answer
refuses a signed image; the admission-check CronJob passes while Kyverno
verifies and fails, naming the missing webhook, once Kyverno is scaled to zero;
and the chart refuses to roll out while Kyverno's webhooks are gone. It signs with a cosign key pair generated for the run (the
publicKey + ignoreTlog configuration above); admitting a real keyless-signed
release digest pulled from GHCR waits on the release packages being publicly
readable.
See also
- Getting started — zero to your first probe result, the "just try one" path.
- Installation — standing up the control plane the agents connect to.
- Configuration reference — every
PROBECTL_*key and config field for every binary. - Agent enrollment & rotation — the identity lifecycle in depth.