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
51 changes: 51 additions & 0 deletions cmd/agent/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"time"

"github.com/RebuildStackCo/runtime-agent/internal/ebpfgate"
"github.com/RebuildStackCo/runtime-agent/internal/nodescan"
)

Expand All @@ -29,6 +30,8 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
interval := fs.Duration("interval", time.Minute, "scan interval; 0 runs a single pass and exits")
endpoint := fs.String("controller-endpoint", "", "controller URL to POST scan reports to; empty runs log-only")
tokenPath := fs.String("token-path", defaultControllerTokenPath, "path to the projected controller-audience ServiceAccount token")
enableEBPF := fs.Bool("enable-ebpf", false, "master switch for the eBPF CPU profiler (ADR 0011); when set, the node checks eBPF readiness at startup and refuses gracefully if the kernel cannot support it")
sysRoot := fs.String("sys", "/sys", "sysfs root used to check kernel BTF at <sys>/kernel/btf/vmlinux")
if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -49,6 +52,16 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
)
defer logger.Info("node scanner stopping")

// eBPF readiness gate (ADR 0011). Evaluated once, before any profiling.
// This slice only gates and reports: the profiler itself is wired in a
// later slice. A refusal degrades to scanner-only — never an escalation to
// privileged — and the scanner below runs regardless. ebpfMetrics is the
// in-process counter; a report consumer surfaces it when the profiler ships.
ebpfMetrics := newEBPFGateMetrics()
if *enableEBPF {
ebpfGate(logger, *procRoot, *sysRoot, ebpfMetrics)
}

scanOnce := func() {
res, err := scanner.Scan()
if err != nil {
Expand Down Expand Up @@ -110,3 +123,41 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
}
}
}

// ebpfGateMetrics counts eBPF readiness outcomes per reason. In this slice it is
// in-process only; a report consumer surfaces it to the controller when the
// profiler ships (ADR 0011). The map key is the low-cardinality refusal reason,
// so a fleet's counts show *why* nodes declined (e.g. kernel_too_old vs
// btf_absent), which is what we would need to know to support new kernels.
type ebpfGateMetrics struct {
ready int
refusals map[ebpfgate.Reason]int
}

func newEBPFGateMetrics() *ebpfGateMetrics {
return &ebpfGateMetrics{refusals: make(map[ebpfgate.Reason]int)}
}

// ebpfGate evaluates the eBPF readiness gate once, logs a clear line, records
// the outcome on m, and returns it. A refusal is deliberately non-fatal: the
// node keeps running the Go-binary scanner rather than escalating to privileged
// (ADR 0011 §2). It logs only the kernel version and BTF fact — never anything
// derived from a customer workload.
func ebpfGate(logger *slog.Logger, procRoot, sysRoot string, m *ebpfGateMetrics) ebpfgate.Result {
res := ebpfgate.Probe(procRoot, sysRoot)
if res.Supported() {
m.ready++
logger.Info("ebpf profile ready",
"kernel", res.KernelVersion,
"btf", res.BTFPresent,
)
return res
}
m.refusals[res.Reason]++
logger.Warn("ebpf profile refused; continuing as scanner",
"reason", string(res.Reason),
"kernel", res.KernelVersion,
"btf", res.BTFPresent,
)
return res
}
115 changes: 115 additions & 0 deletions cmd/agent/node_ebpf_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"bytes"
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/RebuildStackCo/runtime-agent/internal/ebpfgate"
)

// writeGateFixture builds a proc/sys pair the gate can probe: an osrelease file
// under proc and, optionally, a BTF blob under sys.
func writeGateFixture(t *testing.T, osrelease string, btf bool) (proc, sys string) {
t.Helper()
proc, sys = t.TempDir(), t.TempDir()
d := filepath.Join(proc, "sys", "kernel")
if err := os.MkdirAll(d, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "osrelease"), []byte(osrelease), 0o600); err != nil {
t.Fatal(err)
}
if btf {
b := filepath.Join(sys, "kernel", "btf")
if err := os.MkdirAll(b, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(b, "vmlinux"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
return proc, sys
}

func TestEBPFGateSupported(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
proc, sys := writeGateFixture(t, "6.8.0-1064-gcp", true)

m := newEBPFGateMetrics()
res := ebpfGate(logger, proc, sys, m)

if !res.Supported() {
t.Fatalf("Supported() = false, reason %q", res.Reason)
}
if m.ready != 1 || len(m.refusals) != 0 {
t.Errorf("metrics = %+v, want ready=1 no refusals", m)
}
if !strings.Contains(buf.String(), "ebpf profile ready") {
t.Errorf("log missing ready line:\n%s", buf.String())
}
}

func TestEBPFGateRefusesGracefully(t *testing.T) {
cases := []struct {
name string
osrelease string
btf bool
reason ebpfgate.Reason
}{
{"old kernel", "5.4.0", true, ebpfgate.ReasonKernelTooOld},
{"no btf", "6.8.0", false, ebpfgate.ReasonBTFAbsent},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
proc, sys := writeGateFixture(t, tc.osrelease, tc.btf)

m := newEBPFGateMetrics()
res := ebpfGate(logger, proc, sys, m)

if res.Supported() {
t.Fatal("expected a refusal, got supported")
}
if m.ready != 0 {
t.Errorf("ready = %d, want 0", m.ready)
}
if m.refusals[tc.reason] != 1 {
t.Errorf("refusals[%s] = %d, want 1 (all: %+v)", tc.reason, m.refusals[tc.reason], m.refusals)
}
if !strings.Contains(buf.String(), "refused") {
t.Errorf("log missing refusal line:\n%s", buf.String())
}
})
}
}

// TestRunNodeEBPFRefusalDoesNotStop is the graceful-degradation guard: with the
// master switch on and an unsupported kernel, the node must still run the
// scanner to completion (single pass) and return nil — a refusal degrades, it
// never fails the node or escalates (ADR 0011 §2).
func TestRunNodeEBPFRefusalDoesNotStop(t *testing.T) {
proc, sys := writeGateFixture(t, "5.4.0", false)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
done := make(chan error, 1)
go func() {
done <- runNode(context.Background(), logger,
[]string{"-proc", proc, "-sys", sys, "-enable-ebpf", "-interval", "0"})
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("runNode returned error on eBPF refusal: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("runNode did not return")
}
}
147 changes: 147 additions & 0 deletions deploy/node-daemonset-ebpf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Node role — `ebpf` profile variant (ADR 0011). This is an ALTERNATIVE to
# node-daemonset.yaml, not an addition: it deploys the same node role with the
# eBPF CPU profiler enabled. Apply this OR the scanner-only manifest, never both
# (they share names). The eventual Helm chart collapses the two into one
# template gated by the install profile (security.md §3).
#
# The difference from the scanner-only manifest is deliberately small and
# auditable: exactly two added capabilities (CAP_BPF, CAP_PERFMON), one added
# read-only host mount (/sys/kernel/btf for CO-RE symbolization), and the
# -enable-ebpf master switch. Still NO privileged, NO writable host mount, and
# the same zero-RBAC identity. On a kernel < 5.8 or without BTF the profiler
# refuses gracefully and the node keeps running the scanner (ADR 0011 §2).
---
apiVersion: v1
kind: Namespace
metadata:
name: runtime-agent
---
# Zero-RBAC identity — identical to the scanner variant. Bound to nothing;
# automountServiceAccountToken is false on the pod, so no API credential is ever
# placed in the container. Adding eBPF capabilities does NOT grant Kubernetes
# API access: capabilities are a kernel concept, RBAC is an API-server concept.
apiVersion: v1
kind: ServiceAccount
metadata:
name: runtime-agent-node
namespace: runtime-agent
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: runtime-agent-node
namespace: runtime-agent
labels:
app.kubernetes.io/name: runtime-agent
app.kubernetes.io/component: node
spec:
selector:
matchLabels:
app.kubernetes.io/name: runtime-agent
app.kubernetes.io/component: node
template:
metadata:
labels:
app.kubernetes.io/name: runtime-agent
app.kubernetes.io/component: node
spec:
serviceAccountName: runtime-agent-node
# No *API* credential in the container (see the scanner manifest for the
# full argument). The projected token below is audience-bound to the
# controller and rejected by the API server; zero RBAC still holds.
automountServiceAccountToken: false
# Share the host PID namespace so the scanner and profiler see node
# processes (ADR 0009). Host PID plus CAP_BPF + CAP_PERFMON (container,
# below) are this variant's host-level privileges — still no privileged
# flag and no writable host path.
hostPID: true
securityContext:
seccompProfile:
type: RuntimeDefault
runAsUser: 0
runAsNonRoot: false
tolerations:
- operator: Exists
containers:
- name: node
image: runtime-agent:latest
args:
- node
- -proc
- /host/proc
- -interval
- 60s
# Enable the eBPF CPU profiler (ADR 0011). The master switch; the
# eligible set and all ceilings arrive from the node ConfigMap in a
# later slice. On an unsupported kernel the node refuses gracefully.
- -enable-ebpf
# sysfs root for the BTF readiness check (<sys>/kernel/btf/vmlinux),
# served by the read-only /sys/kernel/btf mount below.
- -sys
- /sys
- -controller-endpoint
- http://runtime-agent-controller.runtime-agent.svc:8080/v1/node-inventory
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
securityContext:
privileged: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
# Scanner capability (ADR 0009): read another container's
# /proc/<pid>/exe past the ptrace access check.
- SYS_PTRACE
# eBPF profiler capabilities (ADR 0011, kernel 5.8+): load eBPF
# programs and perform perf sampling. These two — and only these
# two — are what the `ebpf` profile adds over the scanner. There
# is still no privileged flag.
- BPF
- PERFMON
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
# Higher than the scanner's 100m: the profiler samples under an
# overhead ceiling, but perf sampling and on-node symbolization
# cost more than reading buildinfo.
cpu: 250m
memory: 256Mi
volumeMounts:
- name: host-proc
mountPath: /host/proc
readOnly: true
mountPropagation: HostToContainer
- name: host-btf
mountPath: /sys/kernel/btf
readOnly: true
- name: controller-token
mountPath: /var/run/secrets/rebuildstack.co/controller-token
readOnly: true
volumes:
- name: host-proc
hostPath:
path: /proc
type: Directory
# Kernel BTF, read-only. Required by the profiler's CO-RE eBPF programs
# for stack unwinding and on-node symbolization (ADR 0011, security.md
# §7.2). This is the one host path this variant adds over the scanner,
# and it is read-only. Absent on kernels without BTF — the readiness
# gate reports btf_absent and the profiler stays off.
- name: host-btf
hostPath:
path: /sys/kernel/btf
type: Directory
- name: controller-token
projected:
sources:
- serviceAccountToken:
audience: rebuildstack-controller
expirationSeconds: 3600
path: token
19 changes: 11 additions & 8 deletions deploy/node-daemonset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ spec:
automountServiceAccountToken: false
# Share the host PID namespace so the scanner sees every process on the
# node and can open its /proc/<pid>/exe (ADR 0009). This is the only
# host-level privilege the node role takes; there are no eBPF
# capabilities (CAP_BPF/CAP_PERFMON) and no privileged flag.
# host-level privilege the *scanner* variant takes; it adds no eBPF
# capabilities and no privileged flag. The `ebpf` profile variant
# (deploy/node-daemonset-ebpf.yaml, ADR 0011) adds CAP_BPF + CAP_PERFMON
# on top — still without privileged.
hostPID: true
securityContext:
seccompProfile:
Expand Down Expand Up @@ -89,12 +91,13 @@ spec:
capabilities:
drop:
- ALL
# The one capability the node role needs. Container processes are
# non-dumpable, so the kernel's ptrace access check blocks reading
# another container's /proc/<pid>/exe without it (ADR 0009,
# docs/security.md §7). This is NOT an eBPF capability: there is no
# CAP_BPF/CAP_PERFMON here — the buildinfo scanner does not load
# eBPF programs.
# The one capability the *scanner* variant needs. Container
# processes are non-dumpable, so the kernel's ptrace access check
# blocks reading another container's /proc/<pid>/exe without it
# (ADR 0009, docs/security.md §7). SYS_PTRACE is not an eBPF
# capability — this scanner loads no eBPF programs. The `ebpf`
# variant (deploy/node-daemonset-ebpf.yaml) adds CAP_BPF +
# CAP_PERFMON alongside it (ADR 0011, §7.2).
add:
- SYS_PTRACE
resources:
Expand Down
Loading