diff --git a/rpi-nfd-discovery/Dockerfile b/rpi-nfd-discovery/Dockerfile new file mode 100644 index 0000000..72476e6 --- /dev/null +++ b/rpi-nfd-discovery/Dockerfile @@ -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"] diff --git a/rpi-nfd-discovery/PLATFORMS b/rpi-nfd-discovery/PLATFORMS new file mode 100644 index 0000000..8f8418e --- /dev/null +++ b/rpi-nfd-discovery/PLATFORMS @@ -0,0 +1,3 @@ +linux/arm/v7 +linux/arm64 +linux/amd64 diff --git a/rpi-nfd-discovery/README.md b/rpi-nfd-discovery/README.md new file mode 100644 index 0000000..7c35338 --- /dev/null +++ b/rpi-nfd-discovery/README.md @@ -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. diff --git a/rpi-nfd-discovery/VERSION b/rpi-nfd-discovery/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/rpi-nfd-discovery/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/rpi-nfd-discovery/go.mod b/rpi-nfd-discovery/go.mod new file mode 100644 index 0000000..b434bb9 --- /dev/null +++ b/rpi-nfd-discovery/go.mod @@ -0,0 +1,3 @@ +module github.com/davidcollom/dockerfiles/rpi-nfd-discovery + +go 1.23 diff --git a/rpi-nfd-discovery/main.go b/rpi-nfd-discovery/main.go new file mode 100644 index 0000000..b8f1104 --- /dev/null +++ b/rpi-nfd-discovery/main.go @@ -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) + } +} diff --git a/rpi-nfd-discovery/main_test.go b/rpi-nfd-discovery/main_test.go new file mode 100644 index 0000000..0c1fbfb --- /dev/null +++ b/rpi-nfd-discovery/main_test.go @@ -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) + } +}