Skip to content
Draft
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
12 changes: 12 additions & 0 deletions rpi-nfd-discovery/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
ARG TARGETOS=linux
ARG TARGETARCH
ARG TARGETVARIANT
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH GOARM=${TARGETVARIANT#v} go build -trimpath -ldflags="-s -w" -o /out/rpi-nfd-discovery .

FROM scratch
COPY --from=build /out/rpi-nfd-discovery /rpi-nfd-discovery
USER 65532:65532
ENTRYPOINT ["/rpi-nfd-discovery"]
3 changes: 3 additions & 0 deletions rpi-nfd-discovery/PLATFORMS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
linux/arm/v7
linux/arm64
linux/amd64
34 changes: 34 additions & 0 deletions rpi-nfd-discovery/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Raspberry Pi NFD discovery

Minimal Go-based Raspberry Pi hardware discovery for the Node Feature Discovery (NFD) `local` source.

Reads the host's device-tree model and optional `/proc/cpuinfo` revision, and atomically writes an NFD feature file to `features.d/rpi`. Does **not** require Kubernetes API permissions. Does **not** update health/status labels on every sample.

## Labels

- `feature.node.kubernetes.io/rpi-present=true`
- `feature.node.kubernetes.io/rpi-model=3-bplus` (model-dependent)
- `feature.node.kubernetes.io/rpi-revision=a020d3` (if exposed by the kernel)

## Requirements

Your deployed NFD worker must have the `local` source enabled **and must mount the same host path** (`/etc/kubernetes/node-feature-discovery/features.d`) as this DaemonSet.

Example local host test:

```sh
go test ./...
go run . -model /proc/device-tree/model -cpuinfo /proc/cpuinfo \
-output /tmp/rpi-nfd-features -interval 5m
```

Build and publish the multi-arch image before enabling the draft homelab deployment:

```sh
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 \
-t davidcollom/rpi-nfd-discovery:0.1.0 --push .
```

Image is not published merely by committing these source files. The image is intentionally distroless/non-root. The DaemonSet needs a writable host-mounted NFD features directory.

Throttling and undervoltage are deliberately out of scope for static NFD labels; they belong in Prometheus telemetry and alerts. A later exporter can expose `vcgencmd get_throttled` and associated events.
1 change: 1 addition & 0 deletions rpi-nfd-discovery/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
3 changes: 3 additions & 0 deletions rpi-nfd-discovery/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/davidcollom/dockerfiles/rpi-nfd-discovery

go 1.23
114 changes: 114 additions & 0 deletions rpi-nfd-discovery/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)

var safeValue = regexp.MustCompile(`[^a-z0-9.-]+`)

func readModel(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
model := strings.TrimSpace(strings.TrimRight(string(data), "\x00"))
if !strings.HasPrefix(model, "Raspberry Pi ") {
return "", fmt.Errorf("not a Raspberry Pi: %q", model)
}
return model, nil
}

func modelID(model string) string {
name := strings.TrimPrefix(model, "Raspberry Pi ")
name = strings.TrimSpace(strings.Split(name, " Rev ")[0])
name = strings.ToLower(name)
name = strings.ReplaceAll(name, " Model ", " ")
name = strings.ReplaceAll(name, " Plus", "+")
name = strings.ReplaceAll(name, "+", "plus")
name = strings.ReplaceAll(name, " ", "-")
return strings.Trim(safeValue.ReplaceAllString(name, "-"), "-.")
}

func revision(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "Revision" {
value := strings.ToLower(strings.TrimSpace(val))
if regexp.MustCompile(`^[a-f0-9]+$`).MatchString(value) {
return value
}
}
}
return ""
}

func featureContent(model, rev string) string {
var b strings.Builder
fmt.Fprintln(&b, "# Automatically generated by rpi-nfd-discovery")
fmt.Fprintln(&b, "feature.node.kubernetes.io/rpi-present=true")
fmt.Fprintf(&b, "feature.node.kubernetes.io/rpi-model=%s\n", modelID(model))
if rev != "" {
fmt.Fprintf(&b, "feature.node.kubernetes.io/rpi-revision=%s\n", rev)
}
return b.String()
}

func writeAtomic(path, data string) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".rpi-nfd-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0644); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}

func main() {
modelPath := flag.String("model", "/host/device-tree/model", "host device-tree model file")
cpuInfoPath := flag.String("cpuinfo", "/host/cpuinfo", "host /proc/cpuinfo")
output := flag.String("output", "/features.d/rpi", "NFD local feature file")
interval := flag.Duration("interval", 5*time.Minute, "reconciliation interval")
flag.Parse()

if *interval <= 0 {
log.Fatal("interval must be positive")
}
for {
model, err := readModel(*modelPath)
if err != nil {
log.Printf("discovery failed: %v", err)
} else {
content := featureContent(model, revision(*cpuInfoPath))
old, err := os.ReadFile(*output)
if err != nil || string(old) != content {
if err := writeAtomic(*output, content); err != nil {
log.Printf("feature write failed: %v", err)
} else {
log.Printf("discovered %s (%s)", model, modelID(model))
}
}
}
time.Sleep(*interval)
}
}
68 changes: 68 additions & 0 deletions rpi-nfd-discovery/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestModelID(t *testing.T) {
cases := map[string]string{
"Raspberry Pi 3 Model B Plus Rev 1.3": "3-bplus",
"Raspberry Pi 4 Model B Rev 1.4": "4-b",
"Raspberry Pi 5 Model B Rev 1.0": "5-b",
"Raspberry Pi 5 Rev 1.0": "5",
}
for input, want := range cases {
if got := modelID(input); got != want {
t.Errorf("modelID(%q) = %q, want %q", input, got, want)
}
}
}

func TestReadModel(t *testing.T) {
path := filepath.Join(t.TempDir(), "model")
if err := os.WriteFile(path, []byte("Raspberry Pi 3 Model B Plus Rev 1.3\x00"), 0600); err != nil {
t.Fatal(err)
}
model, err := readModel(path)
if err != nil || model != "Raspberry Pi 3 Model B Plus Rev 1.3" {
t.Fatalf("model=%q err=%v", model, err)
}
}

func TestNotRPi(t *testing.T) {
path := filepath.Join(t.TempDir(), "model")
if err := os.WriteFile(path, []byte("Other Board\x00"), 0600); err != nil {
t.Fatal(err)
}
if _, err := readModel(path); err == nil {
t.Fatal("expected an error")
}
}

func TestRevision(t *testing.T) {
path := filepath.Join(t.TempDir(), "cpuinfo")
if err := os.WriteFile(path, []byte("Hardware : BCM2835\nRevision : a020d3\n"), 0600); err != nil {
t.Fatal(err)
}
if got := revision(path); got != "a020d3" {
t.Fatalf("revision=%q", got)
}
}

func TestAtomicOutput(t *testing.T) {
path := filepath.Join(t.TempDir(), "rpi")
content := featureContent("Raspberry Pi 3 Model B Plus Rev 1.3", "a020d3")
if err := writeAtomic(path, content); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), "rpi-model=3-bplus") {
t.Fatalf("unexpected features: %s", got)
}
}
Loading