Chapter 5: The Node Service, Part 1

Chapter 4 ended on an unresolved problem, on purpose: kubelet tries to call NodeGetInfo on your driver as the last step of registering it, that method doesn't exist yet, and node-driver-registrar crash-loops because of it. This chapter writes NodeGetInfo, and its neighbor NodeGetCapabilities, the same test-first way every method in this book gets written. By the end, the exact DaemonSet you already deployed — unchanged — will register successfully.

What these two methods are for

Identity answers questions about the driver as a whole: "who are you," "what can you do." The Node service answers the same two questions, but scoped to one specific node — the one this particular copy of your driver happens to be running on, since a DaemonSet runs one pod per node and each pod only ever knows about its own machine.

  • NodeGetInfo — "who are you, as this node?" It returns an identifier for this node — something the rest of Kubernetes can use later to say "attach this volume to node X" — plus, optionally, a cap on how many volumes this node can hold and where it sits topologically (which region, zone, or rack). This is the exact method kubelet calls, once, as the last step of registration.
  • NodeGetCapabilities — "what are you able to do, on this node?" Some Node plugins support a separate "staging" step before mounting, or can report live volume usage statistics, or support expanding a volume without unmounting it first. Ours doesn't do any of that yet, so — honestly, for now — the answer is "nothing beyond the required basics."

Both, like every Identity method in Chapter 3, are plain request/response calls: no filesystem access, no branching on real conditions. That keeps this chapter's actual new territory narrow and lets it focus on something more interesting than the RPCs themselves — where a node's identity should actually come from.

Driver gains a second embedded interface

Node is a separate gRPC service from Identity, with its own Unimplemented* stub, for the exact reason Chapter 3 walked through for csi.IdentityServer: the real csi.NodeServer interface requires an unexported mustEmbedUnimplementedNodeServer() method that only embedding csi.UnimplementedNodeServer can satisfy, so that a future CSI spec version can add a new Node method without breaking every driver that predates it.

Update internal/driver/driver.go:

package driver

import (
	"github.com/container-storage-interface/spec/lib/go/csi"
)

// HealthChecker describes anything that can report whether the driver's
// backend is usable right now. Identity's Probe method depends on this
// interface — not on any specific way of checking health — so that a
// real filesystem check and a test fake are equally valid things to hand
// it. This is the whole idea behind Dependency Inversion: Driver names
// what it needs, not how that need gets satisfied.
type HealthChecker interface {
	Healthy() bool
}

// Driver holds everything our CSI driver needs to answer gRPC calls.
type Driver struct {
	csi.UnimplementedIdentityServer
	csi.UnimplementedNodeServer

	name    string
	version string
	health  HealthChecker
	nodeID  string
}

// NewDriver builds a Driver. health can be nil — we guard against that in
// Probe — which is useful for tests that don't care about health checks,
// but main.go always passes a real implementation.
func NewDriver(name, version string, health HealthChecker, nodeID string) *Driver {
	return &Driver{
		name:    name,
		version: version,
		health:  health,
		nodeID:  nodeID,
	}
}

// Compile-time checks: these lines do nothing at runtime. They exist
// purely so that if you ever change Driver in a way that breaks either
// interface, the build fails immediately with a clear error — instead of
// failing later, mysteriously, when gRPC tries to register your service.
var _ csi.IdentityServer = (*Driver)(nil)
var _ csi.NodeServer = (*Driver)(nil)

Embedding two different Unimplemented* structs in the same struct is completely ordinary Go — Driver now has two sets of promoted stub methods, one per service, and since IdentityServer and NodeServer don't share any method names, there's no conflict between them. Each compile-time check only cares about its own interface, so both can — and should — live here side by side, exactly the way IdentityServer's did on its own in Chapter 3.

NewDriver also grew a fourth parameter, nodeID string — and this breaks something. Run the tests:

go test ./internal/driver/...
# github.com/yourname/localdir-csi/internal/driver [github.com/yourname/localdir-csi/internal/driver.test]
internal/driver/identity_test.go:10:54: not enough arguments in call to NewDriver
	have (string, string, nil)
	want (string, string, HealthChecker, string)
internal/driver/identity_test.go:26:54: not enough arguments in call to NewDriver
	have (string, string, nil)
	want (string, string, HealthChecker, string)
internal/driver/identity_test.go:66:56: not enough arguments in call to NewDriver
	have (string, string, *fakeHealthChecker)
	want (string, string, HealthChecker, string)
FAIL	github.com/yourname/localdir-csi/internal/driver [build failed]
FAIL

The same lesson Chapter 3 taught the first time NewDriver's signature changed: every existing call site breaks at once, and every one has to be updated, not just the one driving the change. None of these three tests care what node they're pretending to run on, so the fix is the smallest one available — a placeholder string, the same way nil was already the placeholder for a health checker these tests don't need either. In identity_test.go, update all three:

d := NewDriver("localdir.csi.example.com", "0.1.0", nil, "test-node")
d := NewDriver("localdir.csi.example.com", "0.1.0", nil, "test-node")
d := NewDriver("localdir.csi.example.com", "0.1.0", &fakeHealthChecker{healthy: tt.healthy}, "test-node")
go test ./internal/driver/...
ok  	github.com/yourname/localdir-csi/internal/driver	0.012s

Green again, and worth noticing what didn't happen here: nodeID became a plain constructor parameter, not a second interface like HealthChecker. That's deliberate, not an inconsistency. Dependency Inversion earns its keep in Probe because the test needs to control behavior — force the healthy branch, force the unhealthy branch, and watch Probe react differently to each. NodeGetInfo never branches on its node ID at all; it just hands back whatever value it was given. When there's no behavior to substitute, wrapping a value in an interface adds a layer of indirection with nothing behind it to justify it — a plain parameter says exactly as much as the code needs to say, no more.

Writing NodeGetInfo, test-first

One method at a time, the same rhythm every RPC in this book follows: write one test, watch it fail, write only enough code to pass it, then move to the next one. Create internal/driver/node_test.go:

package driver

import (
	"testing"

	"github.com/container-storage-interface/spec/lib/go/csi"
)

func TestNodeGetInfo(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0", nil, "test-node-1")

	resp, err := d.NodeGetInfo(t.Context(), &csi.NodeGetInfoRequest{})
	if err != nil {
		t.Fatalf("NodeGetInfo returned an error: %v", err)
	}

	if resp.NodeId != "test-node-1" {
		t.Errorf("NodeId = %q, want %q", resp.NodeId, "test-node-1")
	}
}

Run it:

go test ./internal/driver/... -v

Red, in the exact shape Chapter 3 taught you to expect once a service's Unimplemented* stub is embedded from the start: not a compile error — NodeGetInfo already exists, promoted from UnimplementedNodeServer — but a real, honest runtime answer that it isn't implemented yet.

=== RUN   TestGetPluginInfo
--- PASS: TestGetPluginInfo (0.00s)
=== RUN   TestGetPluginCapabilities
--- PASS: TestGetPluginCapabilities (0.00s)
=== RUN   TestProbe
=== RUN   TestProbe/backend_is_healthy
=== RUN   TestProbe/backend_is_unhealthy
--- PASS: TestProbe (0.00s)
    --- PASS: TestProbe/backend_is_healthy (0.00s)
    --- PASS: TestProbe/backend_is_unhealthy (0.00s)
=== RUN   TestNodeGetInfo
    node_test.go:14: NodeGetInfo returned an error: rpc error: code = Unimplemented desc = method NodeGetInfo not implemented
--- FAIL: TestNodeGetInfo (0.00s)
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.003s
FAIL

Chapter 3's tests still pass — nothing about them changed — and the one new test fails exactly the way a freshly-embedded, not-yet-implemented method should. Now write just enough to fix it, in a new file — node.go, parallel to identity.go, same as the project layout Chapter 2 sketched out before either file existed:

package driver

import (
	"context"

	"github.com/container-storage-interface/spec/lib/go/csi"
)

func (d *Driver) NodeGetInfo(
	ctx context.Context,
	req *csi.NodeGetInfoRequest,
) (*csi.NodeGetInfoResponse, error) {
	return &csi.NodeGetInfoResponse{
		NodeId: d.nodeID,
	}, nil
}
go test ./internal/driver/... -v
=== RUN   TestGetPluginInfo
--- PASS: TestGetPluginInfo (0.00s)
=== RUN   TestGetPluginCapabilities
--- PASS: TestGetPluginCapabilities (0.00s)
=== RUN   TestProbe
=== RUN   TestProbe/backend_is_healthy
=== RUN   TestProbe/backend_is_unhealthy
--- PASS: TestProbe (0.00s)
    --- PASS: TestProbe/backend_is_healthy (0.00s)
    --- PASS: TestProbe/backend_is_unhealthy (0.00s)
=== RUN   TestNodeGetInfo
--- PASS: TestNodeGetInfo (0.00s)
PASS
ok  	github.com/yourname/localdir-csi/internal/driver	0.002s

Green.

Writing NodeGetCapabilities, test-first

Same rhythm, next method. Add this test to internal/driver/node_test.go, below TestNodeGetInfo:

func TestNodeGetCapabilities(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0", nil, "test-node-1")

	resp, err := d.NodeGetCapabilities(t.Context(), &csi.NodeGetCapabilitiesRequest{})
	if err != nil {
		t.Fatalf("NodeGetCapabilities returned an error: %v", err)
	}

	if len(resp.Capabilities) != 0 {
		t.Errorf("got %d capabilities, want 0", len(resp.Capabilities))
	}
}
go test ./internal/driver/... -v

Red, and only for the method that's actually new — TestNodeGetInfo, written and satisfied a moment ago, keeps passing:

=== RUN   TestGetPluginInfo
--- PASS: TestGetPluginInfo (0.00s)
=== RUN   TestGetPluginCapabilities
--- PASS: TestGetPluginCapabilities (0.00s)
=== RUN   TestProbe
=== RUN   TestProbe/backend_is_healthy
=== RUN   TestProbe/backend_is_unhealthy
--- PASS: TestProbe (0.00s)
    --- PASS: TestProbe/backend_is_healthy (0.00s)
    --- PASS: TestProbe/backend_is_unhealthy (0.00s)
=== RUN   TestNodeGetInfo
--- PASS: TestNodeGetInfo (0.00s)
=== RUN   TestNodeGetCapabilities
    node_test.go:27: NodeGetCapabilities returned an error: rpc error: code = Unimplemented desc = method NodeGetCapabilities not implemented
--- FAIL: TestNodeGetCapabilities (0.00s)
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.003s
FAIL

Add NodeGetCapabilities to node.go, below NodeGetInfo:

func (d *Driver) NodeGetCapabilities(
	ctx context.Context,
	req *csi.NodeGetCapabilitiesRequest,
) (*csi.NodeGetCapabilitiesResponse, error) {
	return &csi.NodeGetCapabilitiesResponse{
		Capabilities: []*csi.NodeServiceCapability{},
	}, nil
}
go test ./internal/driver/... -v
=== RUN   TestGetPluginInfo
--- PASS: TestGetPluginInfo (0.00s)
=== RUN   TestGetPluginCapabilities
--- PASS: TestGetPluginCapabilities (0.00s)
=== RUN   TestProbe
=== RUN   TestProbe/backend_is_healthy
=== RUN   TestProbe/backend_is_unhealthy
--- PASS: TestProbe (0.00s)
    --- PASS: TestProbe/backend_is_healthy (0.00s)
    --- PASS: TestProbe/backend_is_unhealthy (0.00s)
=== RUN   TestNodeGetInfo
--- PASS: TestNodeGetInfo (0.00s)
=== RUN   TestNodeGetCapabilities
--- PASS: TestNodeGetCapabilities (0.00s)
PASS
ok  	github.com/yourname/localdir-csi/internal/driver	0.003s

Green. NodeGetCapabilities returning an empty slice isn't a shortcut — it's the honest answer for a driver that doesn't yet implement staging, volume statistics, or expansion. The same Open/Closed point Chapter 3 made about GetPluginCapabilities applies here unchanged: this is one slice literal, so supporting a new capability later — if a future chapter adds one — means adding an entry to it, not restructuring how this method works or rewriting this test.

Where a node's identity actually comes from

NodeGetInfo is done, but it's currently only as correct as the string you hand NewDriver, and main.go doesn't have a real one yet. The obvious first instinct is os.Hostname() — Go's standard library already knows the machine's hostname, so why not just ask it?

Because inside a Kubernetes pod, "the machine's hostname" and "the node's hostname" are two different things. By default, a container's hostname is set to its pod's name, not the underlying node's — os.Hostname() inside your driver container would return something like localdir-csi-node-4kx9p, not the actual node kubelet is running on. Feed that into NodeGetInfo, and the node ID Kubernetes stores for this node is actually a pod name that gets deleted and replaced the next time this pod restarts — exactly the kind of subtly wrong value that works fine in a five-minute test and quietly breaks something later.

The real node name lives in the Kubernetes API — specifically, in the Pod object's own spec.nodeName field, filled in by the scheduler once it decides which node a pod runs on. Getting that value into your container is what the Downward API is for: a mechanism for exposing facts Kubernetes already knows about a pod — its name, its namespace, and yes, the node it landed on — as environment variables or files inside that pod's own containers, without your code ever having to ask the Kubernetes API directly. You declare which fact you want in the pod spec; kubelet fills in the value at container start.

Add this to the localdir-csi container in deploy/node.yaml:

        - name: localdir-csi
          image: localdir-csi:dev
          imagePullPolicy: IfNotPresent
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
          volumeMounts:
            - name: socket-dir
              mountPath: /csi
            - name: data-dir
              mountPath: /data

fieldPath: spec.nodeName is the Downward API asking for exactly that one field off this pod's own spec; NODE_NAME is just the environment variable name we're choosing to expose it under inside the container — your driver reads it with an ordinary os.Getenv("NODE_NAME"), no different from reading any other environment variable.

Update cmd/localdir-csi/main.go:

package main

import (
	"flag"
	"log"
	"net"
	"os"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"

	"github.com/container-storage-interface/spec/lib/go/csi"
	"github.com/yourname/localdir-csi/internal/driver"
)

const (
	driverName    = "localdir.csi.example.com"
	driverVersion = "0.1.0"
)

func main() {
	endpoint := flag.String("endpoint", "unix:///csi/csi.sock",
		"gRPC endpoint the driver listens on, as a unix:// URI")
	dataDir := flag.String("data-dir", "/data",
		"directory the health check reads and writes to confirm local storage is usable")
	flag.Parse()

	socketPath, err := driver.SocketPathFromEndpoint(*endpoint)
	if err != nil {
		log.Fatalf("invalid -endpoint: %v", err)
	}

	nodeID := os.Getenv("NODE_NAME")
	if nodeID == "" {
		// NODE_NAME is set by the Downward API in deploy/node.yaml, so
		// this branch never runs in the cluster. It exists purely so
		// `make run`, on your own laptop, still has something sensible
		// to report — your laptop genuinely doesn't have a Kubernetes
		// node name, so falling back to its real hostname is the closest
		// honest answer available locally.
		hostname, err := os.Hostname()
		if err != nil {
			log.Fatalf("NODE_NAME not set, and failed to read hostname as a fallback: %v", err)
		}
		nodeID = hostname
	}

	// If a socket file from a previous run is still sitting there, remove
	// it. Without this, trying to listen on the same path a second time
	// fails with "address already in use" — a file on disk, not an
	// actual network port, but Go's net package still treats it that way.
	if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
		log.Fatalf("failed to remove existing socket: %v", err)
	}

	listener, err := net.Listen("unix", socketPath)
	if err != nil {
		log.Fatalf("failed to listen on %s: %v", socketPath, err)
	}

	d := driver.NewDriver(
		driverName,
		driverVersion,
		&driver.LocalDirHealthChecker{Root: *dataDir},
		nodeID,
	)

	server := grpc.NewServer()
	csi.RegisterIdentityServer(server, d)
	csi.RegisterNodeServer(server, d)
	reflection.Register(server)

	log.Printf("localdir-csi listening on %s (node %s)", socketPath, nodeID)
	if err := server.Serve(listener); err != nil {
		log.Fatalf("server stopped: %v", err)
	}
}

Two changes below the flags, both small: nodeID is resolved once, right after the flags are parsed, with the laptop fallback described above; and csi.RegisterNodeServer(server, d) sits right next to csi.RegisterIdentityServer(server, d) — the same one-line-per-service pattern established in Chapter 3, extended by exactly one line now that Driver answers a second service.

Redeploying

Nothing about the DaemonSet's shape changed — no new container, no new mount — so the same commands from Chapter 4 apply:

make deploy
kubectl get pods -l app=localdir-csi -w

Give it a few seconds. Where Chapter 4 showed RESTARTS climbing on the node-driver-registrar container, it should now hold steady at 0 — kubelet's NodeGetInfo call finally has something real to answer with, registration succeeds, and there's nothing left to crash-loop over.

Confirm it the same way Chapter 4 confirmed the absence — by checking for the presence, this time:

kubectl get nodes
kubectl get csinode <node-name> -o yaml

Under spec.drivers, you should now find an entry for localdir.csi.example.com, with nodeID set to whatever your kind node is actually named. That's the same object, the same field, that Chapter 4 checked and came up empty — now populated, because the one piece of information kubelet was missing finally exists.

What you should have now

  • internal/driver/driver.go with Driver embedding both csi.UnimplementedIdentityServer and csi.UnimplementedNodeServer, and a nodeID field threaded through the constructor as a plain string, not an interface
  • internal/driver/node.go implementing NodeGetInfo and NodeGetCapabilities, both tested before they were written
  • cmd/localdir-csi/main.go resolving a real node ID from the NODE_NAME environment variable — set via the Kubernetes Downward API in deploy/node.yaml — with a hostname-based fallback for local runs, and registering the Node service alongside Identity
  • A driver pod in your kind cluster that kubelet now fully trusts, confirmed by a CSINode entry that didn't exist at the end of Chapter 4

Two methods of csi.NodeServer are implemented; the two that actually matter for a pod to use a volume — NodePublishVolume and NodeUnpublishVolume, the calls that turn a mounted directory into something a container can read and write through — are next, in Chapter 6.