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.gowithDriverembedding bothcsi.UnimplementedIdentityServerandcsi.UnimplementedNodeServer, and anodeIDfield threaded through the constructor as a plain string, not an interfaceinternal/driver/node.goimplementingNodeGetInfoandNodeGetCapabilities, both tested before they were writtencmd/localdir-csi/main.goresolving a real node ID from theNODE_NAMEenvironment variable — set via the Kubernetes Downward API indeploy/node.yaml— with a hostname-based fallback for local runs, and registering the Node service alongside Identity- A driver pod in your
kindcluster that kubelet now fully trusts, confirmed by aCSINodeentry 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.