Chapter 3: The Identity Service

This is the first chapter where you'll run real code and see it answer a real question over the network. We're going to keep it small on purpose — three methods, none of which touch storage at all — so you can focus entirely on "how does a CSI driver's code fit together" without also worrying about mounts, volumes, or the Kubernetes API.

What the Identity service is for, in plain words

Before Kubernetes trusts your driver to create or mount anything, it wants to ask it two simple questions: "who are you?" and "are you still alive and working?"

That's it. That's the whole Identity service. It doesn't create volumes. It doesn't mount anything. It just answers questions about itself. Every CSI driver — no matter what storage system it talks to — implements this exact same service, because every driver needs to answer these same two questions.

The three methods, in the plainest terms possible:

  • GetPluginInfo — "What's your name and version number?" Kubernetes uses this to tell your driver apart from every other driver installed in the cluster.
  • GetPluginCapabilities — "What are you able to do?" Some drivers can only create and delete volumes. Others can also take snapshots, or resize volumes. This method is how your driver tells Kubernetes which of those things it supports, so Kubernetes doesn't try to ask for a snapshot from a driver that never learned how to make one.
  • Probe — "Are you healthy right now?" This gets called repeatedly, over and over, for as long as your driver runs. It's a health check.

None of these three methods need to know anything about your storage backend. That's exactly why we're starting here — you get to learn how the plumbing works (the gRPC server, the Unix socket, how a request turns into a Go function call) without also learning how to create a volume at the same time.

A note on how we'll write code from here on

Starting this chapter, every piece of driver code in this book follows two practices, and it's worth explaining both in plain terms once, up front, so the code doesn't feel like it's following rules you can't see.

Test-Driven Development (TDD) means: write a test for a small piece of behavior before you write the code that makes it work. Run the test, watch it fail — you have to actually see it fail, not assume it would — then write the smallest amount of code that makes it pass, then clean that code up if it needs it. This cycle has a name — red, green, refactor — red for the failing test, green for passing, refactor for tidying up once you're safely covered by a passing test. We'll do this for every method in this chapter.

SOLID is five design guidelines for object-oriented code, one letter each. We won't lecture on all five in the abstract — we'll point out each one exactly where it shows up in the driver, so you see the guideline and the reason for it side by side instead of as a separate rule to memorize:

  • S — Single Responsibility: a piece of code should have one reason to change.
  • O — Open/Closed: you should be able to add new behavior without editing code that already works.
  • L — Liskov Substitution: anything implementing an interface should be swappable for anything else implementing that same interface, with no surprises.
  • I — Interface Segregation: don't force code to depend on methods it doesn't use.
  • D — Dependency Inversion: code should depend on an interface describing what it needs, not on one specific concrete implementation of it.

Two of these are already true of the project without us doing anything: Interface Segregation is baked into the CSI spec itself — IdentityServer, ControllerServer, and NodeServer are three separate Go interfaces, not one giant interface with every method mashed together. Code that only needs to check plugin info never has to know ControllerServer exists. Liskov Substitution falls out of that too — since Driver implements csi.IdentityServer purely through its method signatures, gRPC can hand it to csi.RegisterIdentityServer and it works, no special cases.

The other three — Single Responsibility, Open/Closed, Dependency Inversion — take actual design decisions on our part, and we'll make those decisions below, with tests, as we go.

The Driver struct: one home for everything

Every method you write in this book — whether it belongs to Identity, Controller, or Node — is going to be a method on one shared Go struct called Driver. Right now it'll be nearly empty: just enough to give the tests below something to construct. Later chapters will add fields as the Controller and Node services need somewhere to keep state.

Create internal/driver/driver.go:

package driver

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

// Driver holds everything our CSI driver needs to answer gRPC calls.
type Driver struct {
	// Embedding UnimplementedIdentityServer is what the csi package
	// requires from every IdentityServer implementation — more on why,
	// and what it actually does, just below.
	csi.UnimplementedIdentityServer

	name    string
	version string
}

// NewDriver builds a Driver. We pass the name and version in from main.go
// rather than hardcoding them here, so tests can construct a Driver with
// whatever values they like.
func NewDriver(name, version string) *Driver {
	return &Driver{
		name:    name,
		version: version,
	}
}

// Compile-time check: this line does nothing at runtime. It exists purely
// so that if you ever change Driver in a way that breaks the
// IdentityServer 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)

Two things in this file are worth understanding properly, because you'll meet both patterns again for ControllerServer and NodeServer in later chapters.

The embedded csi.UnimplementedIdentityServer is the CSI spec's answer to a real problem: what happens if a future version of the spec adds a fourth method to IdentityServer? Without some kind of safety valve, every existing driver in the world — including this one — would stop compiling the moment it upgraded to that newer version of the csi package, simply because it would no longer satisfy the interface. UnimplementedIdentityServer is that safety valve: it's a struct with a stub version of every method the interface currently requires, each one just returning a gRPC error saying "not implemented." Embed it in Driver, and Driver automatically satisfies IdentityServer — today, and for any method the spec adds later — without you writing a line for it. Go's embedding rules mean those stub methods become part of Driver's own method set, promoted up from the embedded field, exactly as if you'd written them by hand. IdentityServer also requires one unexported method, mustEmbedUnimplementedIdentityServer(), that exists for no reason other than to force this embedding: there's no way to satisfy it except by embedding UnimplementedIdentityServer, so it's not possible to accidentally build an IdentityServer implementation that skipped this safety valve. Forget the embed, and Go tells you exactly that: *Driver does not implement csi.IdentityServer (missing method mustEmbedUnimplementedIdentityServer).

The compile-time check — var _ csi.IdentityServer = (*Driver)(nil) — is a small, well-known Go trick worth learning once, because you'll use it constantly from here on. It declares a variable named _ (Go's "throw this away, I don't need it" name) of type csi.IdentityServer, and assigns it a nil *Driver. Go only allows that assignment if *Driver genuinely satisfies every method csi.IdentityServer requires. Thanks to the embedded stub above, it already does — so this line compiles cleanly right now, even though Driver doesn't have a single real Identity method of its own yet. That's exactly the outcome the embedding is designed to produce: a driver that builds successfully at every stage of being written, always answering honestly about what it can and can't do yet, rather than refusing to compile at all until every method is finished.

What we're testing first

GetPluginInfo is the simplest possible thing to test: given a driver with a certain name and version, does it report that name and version back? No branching, no edge cases yet. Good first target for TDD, because the test itself takes thirty seconds to write and you can focus entirely on the rhythm — red, green, refactor — rather than on a hard problem.

Create internal/driver/identity_test.go:

package driver

import (
	"testing"

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

func TestGetPluginInfo(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0")

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

	if resp.Name != "localdir.csi.example.com" {
		t.Errorf("Name = %q, want %q", resp.Name, "localdir.csi.example.com")
	}
	if resp.VendorVersion != "0.1.0" {
		t.Errorf("VendorVersion = %q, want %q", resp.VendorVersion, "0.1.0")
	}
}

One thing worth noticing before running this: the request is built with t.Context() as its first argument, not the more commonly seen context.Background(). Every gRPC method — including every method you'll write in this entire book — takes a context.Context as its first parameter, because gRPC uses it to carry two things alongside a call: a signal meaning "give up on this, right now" (cancellation), and, optionally, a point in time meaning "give up on this if it's not done by then" (a deadline). A context.Context doesn't do any work itself — it's just a small object your code can check, at any point, to ask "should I stop what I'm doing?"

context.Background() gives you the plainest possible context: one that never cancels and never has a deadline. It's a fine starting point when nothing else is available, but it isn't tied to anything — nothing about it knows this call happens to be running inside a test.

t.Context(), a method added directly to Go's testing package in Go 1.24, gives you a context that's scoped to the test that requested it: Go cancels it automatically the moment the test finishes — right before any t.Cleanup functions run — whether the test passed, failed, or was stopped early. For these three Identity methods, this makes no visible difference yet, because none of them look at ctx at all. It's still the right default to reach for from here on, though: later chapters add Controller and Node methods that do real work — copying data, running mount — and check ctx.Done() to bail out early if the caller gave up. Using t.Context() now, in tests where it doesn't matter yet, means you never have to remember to switch away from context.Background() later, in a test where it does. Worth being honest about that promise, though: this book's own Controller and Node methods never actually call ctx.Done() — Chapter 11 names that as one of this driver's real, still- open limitations, not something later chapters quietly deliver. Passing ctx through everywhere is table stakes for a method that could honor cancellation; actually checking it is a separate, deliberate piece of work this book doesn't do.

One consequence worth flagging: because t.Context() lives on *testing.T rather than the context package, this test file has no direct use for "context" at all — notice it isn't in the import block above. If you copy this from an older gRPC tutorial that still imports "context" for context.Background(), Go's compiler will flag it as an unused import the moment you switch to t.Context().

Run it:

go test ./internal/driver/...

This is the moment the embedded stub in driver.go actually matters: d.GetPluginInfo already exists — promoted from UnimplementedIdentityServer — so the package builds without complaint. The test runs. And it fails, not because anything failed to compile, but because the stub does exactly the job it's built for:

--- FAIL: TestGetPluginInfo (0.00s)
    identity_test.go:14: GetPluginInfo returned an error: rpc error: code = Unimplemented desc = method GetPluginInfo not implemented
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.002s

That's red — just a different flavor of red than "undefined method." Nothing here is broken; GetPluginInfo genuinely has no real logic yet as far as Driver is concerned, and the embedded stub says so as clearly as a compile error would, just one step later, at test-run time instead of build time. This is the shape every RPC's first red step takes from here on: never "doesn't exist," always a real, honest response your driver actually sent — just not the one the test wants.

Now write just enough to go green. This is internal/driver/identity.go:

package driver

import (
	"context"

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

func (d *Driver) GetPluginInfo(
	ctx context.Context,
	req *csi.GetPluginInfoRequest,
) (*csi.GetPluginInfoResponse, error) {
	return &csi.GetPluginInfoResponse{
		Name:          d.name,
		VendorVersion: d.version,
	}, nil
}

This method, defined directly on *Driver, takes over from the embedded stub of the same name. Go always prefers a method defined directly on a type over one promoted from an embedded field, so nothing extra is needed to make the override "stick" — the moment this function exists, every call to d.GetPluginInfo(...) reaches it instead of the stub.

Notice identity.go itself still imports "context" — that's expected and different from the test file. The production method's signature has to name the context.Context type so it satisfies csi.IdentityServer, regardless of what kind of context a caller happens to pass in. Only the test file, which only ever constructs a context via t.Context() rather than naming the type, was able to drop the import.

go test ./internal/driver/...
ok  	github.com/yourname/localdir-csi/internal/driver	0.002s

That's green. Nothing to refactor yet — the code's already about as simple as it can be. We'll hit a real refactor step later in this chapter.

GetPluginCapabilities: the same rhythm, then a design decision

Same pattern — test first:

func TestGetPluginCapabilities(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0")

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

	if len(resp.Capabilities) != 1 {
		t.Fatalf("got %d capabilities, want 1", len(resp.Capabilities))
	}

	service := resp.Capabilities[0].GetService()
	if service == nil {
		t.Fatal("expected a Service capability, got something else")
	}
	if service.Type != csi.PluginCapability_Service_CONTROLLER_SERVICE {
		t.Errorf("capability type = %v, want CONTROLLER_SERVICE", service.Type)
	}
}

Red, the same way as before: GetPluginCapabilities currently resolves to the embedded stub, so it comes back with a gRPC Unimplemented error instead of any capabilities at all.

--- FAIL: TestGetPluginCapabilities (0.00s)
    identity_test.go:XX: GetPluginCapabilities returned an error: rpc error: code = Unimplemented desc = method GetPluginCapabilities not implemented
FAIL

Now the implementation:

func (d *Driver) GetPluginCapabilities(
	ctx context.Context,
	req *csi.GetPluginCapabilitiesRequest,
) (*csi.GetPluginCapabilitiesResponse, error) {
	return &csi.GetPluginCapabilitiesResponse{
		Capabilities: []*csi.PluginCapability{
			{
				Type: &csi.PluginCapability_Service_{
					Service: &csi.PluginCapability_Service{
						Type: csi.PluginCapability_Service_CONTROLLER_SERVICE,
					},
				},
			},
		},
	}, nil
}

Green. Here's where Open/Closed becomes a real, concrete concern rather than an abstract rule: by Chapter 10 (snapshots), this method needs to report a second capability. If capabilities were, say, three separate if statements each building and returning early, adding a fourth capability later would mean editing existing, working, already-tested branches — exactly what Open/Closed says to avoid. Because we built it as one slice literal instead, adding capability #2 later means adding an entry to the slice, not changing how capability #1 is built or tested. Nothing about TestGetPluginCapabilities above will need to change when Chapter 10 adds a second entry — it only asserts Capabilities[0], and a new test will assert Capabilities[1]. Open for extension, closed for modification, made real instead of abstract.

Probe: where Dependency Inversion actually earns its keep

This is the one worth slowing down for. It would be easy to make Probe simply return ready: true unconditionally — there'd be nothing to check, so nothing to get wrong. But think about what Probe is for: a real driver might need to say "not ready" if, say, it can't reach its storage backend yet. Our driver's backend is the local disk, so it should say "not ready" if that disk isn't there or isn't writable.

Here's the design question TDD forces you to confront immediately: how do you write a test for "Probe returns not-ready when the disk is unavailable" without actually detaching a real disk in your test suite? You can't, and you shouldn't try — that's a slow, flaky test that has nothing to do with the logic you're actually verifying.

The fix is Dependency Inversion: instead of Probe reaching out and checking the filesystem itself, Driver depends on a small interface that describes "something that can report whether it's healthy" — and the real filesystem check becomes just one implementation of that interface, swapped in at startup. In tests, we swap in a fake instead.

Test first. This test needs Driver to accept a health checker, which doesn't exist yet — write the test as if it already does:

type fakeHealthChecker struct {
	healthy bool
}

func (f *fakeHealthChecker) Healthy() bool {
	return f.healthy
}

func TestProbe(t *testing.T) {
	tests := []struct {
		name    string
		healthy bool
		want    bool
	}{
		{name: "backend is healthy", healthy: true, want: true},
		{name: "backend is unhealthy", healthy: false, want: false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			d := NewDriver("localdir.csi.example.com", "0.1.0", &fakeHealthChecker{healthy: tt.healthy})

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

			if resp.GetReady().GetValue() != tt.want {
				t.Errorf("Ready = %v, want %v", resp.GetReady().GetValue(), tt.want)
			}
		})
	}
}

A couple of things about this test worth calling out:

  • The fake is handed straight to NewDriver as a third argument — a constructor that doesn't exist yet, on purpose. TDD's "write the test as if it already does" applies to function signatures, not just to whether a method exists: writing the call the way we want it to look, before it compiles, is what drives the very next step.
  • fakeHealthChecker is a tiny struct we control completely — we can make it report healthy or unhealthy on command, instantly, with no real disk involved. This is the entire payoff of Dependency Inversion: the test gets to substitute a fake for the real thing because Driver only ever asked for something with a Healthy() bool method, never specifically for "the real filesystem checker."
  • This is a table-driven test — a Go convention where instead of writing one test function per case, you write a slice of cases and loop over them with t.Run. It's not required by TDD or SOLID, but it's the idiomatic way to write "same logic, different inputs" tests in Go, and you'll see this shape constantly for the rest of the book.
  • Notice t.Context() is called on the inner t — the one belonging to the subtest created by t.Run, not the outer one belonging to TestProbe itself. Each subtest gets its own context, canceled when that specific subtest finishes, rather than sharing one context across every case in the table.

Run it — red, and this time it's a compile error again, not a stub response, because NewDriver itself is the thing that doesn't match yet — there's no embedded stub standing in for a constructor, only for interface methods:

./identity_test.go:XX:13: too many arguments in call to NewDriver
	have (string, string, *fakeHealthChecker)
	want (string, string)

Now make it real. First, the interface, and an update to the Driver struct and NewDriver we wrote earlier in this chapter — edit 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

	name    string
	version string
	health  HealthChecker
}

// 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, wired up later in this chapter, always passes a real
// implementation.
func NewDriver(name, version string, health HealthChecker) *Driver {
	return &Driver{
		name:    name,
		version: version,
		health:  health,
	}
}

// Compile-time check: this line does nothing at runtime. It exists purely
// so that if you ever change Driver in a way that breaks the
// IdentityServer 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)

Notice NewDriver's signature changed — it now takes a HealthChecker as a third argument. This is a real, deliberate breaking change to code we wrote earlier in the book, and it's worth sitting with why we're doing it here rather than bolting health-checking on some other way: if Probe reached into a global variable, or constructed its own filesystem checker internally, there'd be no way for a test to intercept it. Taking the dependency in through the constructor is what makes it substitutable. TestProbe's own call to NewDriver, written a moment ago, already matches this new three-argument shape — that's the payoff of having written it that way from the start. Two other calls, elsewhere in this file, are not so lucky yet.

go test ./internal/driver/...
./identity_test.go:XX:9: not enough arguments in call to NewDriver
	have (string, string)
	want (string, string, HealthChecker)
./identity_test.go:XX:9: not enough arguments in call to NewDriver
	have (string, string)
	want (string, string, HealthChecker)

These two are TestGetPluginInfo and TestGetPluginCapabilities — written earlier in this chapter, before HealthChecker existed, calling NewDriver with its old two-argument shape. This is the real, visible cost of the breaking change from a moment ago: Go's compiler won't let a stale call site slide by silently just because it used to be valid. Neither test cares about health checking at all — neither one ever calls Probe — so the fix is the smallest one available: pass nil for the third argument in both. HealthChecker is an interface, and a nil interface value is entirely legal in Go; it only becomes a problem if something actually tries to call a method on it, and nothing in either test does.

func TestGetPluginInfo(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0", nil)
	// ...unchanged below this line
func TestGetPluginCapabilities(t *testing.T) {
	d := NewDriver("localdir.csi.example.com", "0.1.0", nil)
	// ...unchanged below this line

Try again:

go test ./internal/driver/...

Every call site now matches, so the whole package builds — and with the build no longer blocked, one more red step surfaces, in exactly the same shape GetPluginInfo's did earlier: the embedded stub answers honestly that Probe isn't implemented yet either.

--- FAIL: TestProbe/backend_is_healthy (0.00s)
    identity_test.go:XX: Probe returned an error: rpc error: code = Unimplemented desc = method Probe not implemented
--- FAIL: TestProbe/backend_is_unhealthy (0.00s)
    identity_test.go:XX: Probe returned an error: rpc error: code = Unimplemented desc = method Probe not implemented
FAIL

Both branches of the table fail the same way, which makes sense — neither one has reached any real logic yet; both are still hitting the same stub. Now Probe itself, in identity.go:

package driver

import (
	"context"

	"github.com/container-storage-interface/spec/lib/go/csi"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

func (d *Driver) Probe(
	ctx context.Context,
	req *csi.ProbeRequest,
) (*csi.ProbeResponse, error) {
	ready := d.health != nil && d.health.Healthy()
	return &csi.ProbeResponse{
		Ready: wrapperspb.Bool(ready),
	}, nil
}

That wrapperspb.Bool(ready) deserves a second look, because the obvious thing to write instead is &ready — a plain *bool — and that produces a confusing compile error the first time you see it: cannot use &ready (value of type *bool) as *wrapperspb.BoolValue value in struct literal. The CSI spec doesn't define ProbeResponse.Ready as a plain boolean; it defines it as a google.protobuf.BoolValue — a tiny wrapper message that holds one boolean field, named Value. This isn't about what a Go *bool can represent — a *bool already distinguishes true, false, and "not set" (nil) just fine. It's about what protobuf itself can represent: proto3 has no native optional scalar type, so a plain bool field can't be told apart from "false" on the wire either. google.protobuf.BoolValue is the standard library's fix for that gap — a message wrapping one field, which protobuf's normal presence rules already handle — and the CSI spec chose it for ProbeResponse.Ready specifically so "not ready" and "hasn't said yet" stay distinguishable across the wire, not just in memory. Go's generated code for the CSI spec follows that wrapper faithfully, so Ready is a *wrapperspb.BoolValue, not a *bool, and building one means either &wrapperspb.BoolValue{Value: ready} or, more idiomatically, the helper constructor wrapperspb.Bool(ready) used above, from google.golang.org/protobuf/types/known/wrapperspb. It's worth noticing that TestProbe's own assertion — resp.GetReady().GetValue() — was already written correctly against this wrapper type: GetValue() only exists because Ready is a message, not a plain bool. The test had the right shape from the start; the implementation just needed to match it.

If this is the first time anything in this project has imported from google.golang.org/protobuf directly, run this once so go.mod records it properly:

go get google.golang.org/protobuf/types/known/wrapperspb

It's likely already sitting in your module graph — the csi package itself depends on it — but Go's module system wants anything you import directly, in your own code, listed as a direct requirement in go.mod, not left as something merely pulled in indirectly through another dependency. Without this, go build can fail with no required module provides package google.golang.org/protobuf/types/known/wrapperspb.

go test ./internal/driver/... -v
--- PASS: TestGetPluginInfo (0.00s)
--- PASS: TestGetPluginCapabilities (0.00s)
--- PASS: TestProbe (0.00s)
    --- PASS: TestProbe/backend_is_healthy (0.00s)
    --- PASS: TestProbe/backend_is_unhealthy (0.00s)
ok  	github.com/yourname/localdir-csi/internal/driver	0.003s

Green, including both branches of the table-driven test. This is the payoff, made visible: we just proved Probe correctly reports unhealthy under a condition we can't easily create in real life — a broken storage backend — without ever touching a real disk.

Refactor step: the real health checker

We've been testing against a fake. We still need a real implementation for the actual driver to use once it's running for real. This is a good moment to point at Single Responsibility concretely: this real checker has exactly one job — "can I write to and read from this directory?" — and nothing about Identity, gRPC, or Probe belongs inside it. Create internal/driver/health.go:

package driver

import (
	"os"
	"path/filepath"
)

// LocalDirHealthChecker checks that a given directory exists and is
// writable, by actually writing a small file to it and reading it back.
// It's the "real" implementation of HealthChecker that main.go will use
// — the fakeHealthChecker in identity_test.go exists purely for tests.
type LocalDirHealthChecker struct {
	Root string
}

func (h *LocalDirHealthChecker) Healthy() bool {
	f, err := os.CreateTemp(h.Root, ".health-probe-*")
	if err != nil {
		return false
	}
	path := f.Name()
	defer os.Remove(path)
	defer f.Close()

	if _, err := f.WriteString("ok"); err != nil {
		return false
	}
	_, err = os.ReadFile(path)
	return err == nil
}

var _ HealthChecker = (*LocalDirHealthChecker)(nil)

That last line is the same compile-time interface check pattern from earlier in this chapter, now protecting a second implementation of HealthChecker instead of IdentityServer.

os.CreateTemp matters more than it looks like it should. An earlier version of this checker wrote and read back one fixed name, .health-probe, every time — which works fine for a single caller, but kubelet can and does call Probe from more than one goroutine, and nothing here serializes those calls. Two concurrent probes sharing one fixed filename can race: one probe's os.Remove can delete the file a second probe just wrote, and that second probe's own ReadFile then reports "unhealthy" for a directory that's perfectly fine. CreateTemp gives every call its own uniquely-named file (the * in the pattern gets replaced with a random suffix), so concurrent probes no longer share anything to race over.

This one, unlike everything else in this chapter so far, genuinely does need to touch a real filesystem to test — t.TempDir(), from Go's testing package, gives every test its own directory that's cleaned up automatically:

func TestLocalDirHealthChecker_HealthyRoot(t *testing.T) {
	h := &LocalDirHealthChecker{Root: t.TempDir()}
	if !h.Healthy() {
		t.Error("Healthy() = false, want true for a writable directory")
	}
}

func TestLocalDirHealthChecker_MissingRoot(t *testing.T) {
	h := &LocalDirHealthChecker{Root: filepath.Join(t.TempDir(), "does-not-exist")}
	if h.Healthy() {
		t.Error("Healthy() = true, want false for a root that doesn't exist")
	}
}

func TestLocalDirHealthChecker_UnwritableRoot(t *testing.T) {
	root := t.TempDir()
	if err := os.Chmod(root, 0o500); err != nil {
		t.Fatalf("os.Chmod: %v", err)
	}
	t.Cleanup(func() { os.Chmod(root, 0o750) })

	h := &LocalDirHealthChecker{Root: root}
	if h.Healthy() {
		t.Error("Healthy() = true, want false for a read-only directory")
	}
}

Three real filesystem states, three real outcomes: a writable directory, a root that was never created, and a root that exists but refuses writes. Probe's own branching logic was already fully covered by the fake HealthChecker earlier in this chapter — these three tests cover the one thing the fake couldn't: whether LocalDirHealthChecker itself correctly turns "can I write and read a file here" into true or false against a real directory.

Add a test target to the Makefile

Same reasoning as make deploy in Chapter 2 — you'll run this constantly:

test:
	go test ./... -v

From here on, "run the tests" means make test.

What just happened, and why it mattered

We wrote three methods, and every line of production code exists because a failing test demanded it, not because we typed it and hoped. Along the way, one real design decision came out of that process rather than being imposed on it from outside: Probe doesn't know or care how health gets checked, because depending on an interface instead of a concrete filesystem call is what let us test the interesting logic (the branching in Probe) completely separately from the boring, hard-to-test logic (actually touching a disk).

That split — interesting branching logic tested with fakes, boring I/O tested separately or not at all yet — is a pattern you'll see repeat in every chapter from here on, especially once Controller and Node need to call out to real storage operations.

Wiring up the gRPC server

Every method we built above only exists as Go code that tests call directly — nothing's listening on the network yet. Create cmd/localdir-csi/main.go:

package main

import (
	"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"
	socketPath    = "/csi/csi.sock"
)

func main() {
	// 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)
	}

	// This is the one line from Chapter 2.5 that's different from a
	// typical gRPC tutorial: "unix" instead of "tcp", and a file path
	// instead of a port number.
	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: "/data"},
	)

	server := grpc.NewServer()

	// This line connects our Driver struct to gRPC's Identity service
	// machinery. From this point on, whenever a GetPluginInfo /
	// GetPluginCapabilities / Probe call arrives on the socket, gRPC
	// routes it to the matching method on d.
	csi.RegisterIdentityServer(server, d)

	// Reflection is a second, separate gRPC service, distinct from
	// Identity, that answers one question: "what services and methods do
	// you have, and what do their messages look like?" A real CSI sidecar
	// never needs to ask that question — it's compiled against the exact
	// same generated csi package we are, so it already knows. A tool like
	// grpcurl, run by a human from a terminal, has no such advance
	// knowledge, and no .proto file handy either, unless it's told where
	// to look. Reflection is how it asks the server directly instead.
	reflection.Register(server)

	log.Printf("localdir-csi listening on %s", socketPath)

	// Serve blocks forever, handling calls as they arrive, until
	// something goes wrong or the process is killed.
	if err := server.Serve(listener); err != nil {
		log.Fatalf("server stopped: %v", err)
	}
}

Nothing here is CSI-specific except the socket path and which Register*Server function we call. This is the same shape every gRPC server in Go has: get a listener, make a server, register your service(s) against it, call Serve.

Notice main.go builds the real LocalDirHealthChecker, pointed at /data, and hands it to NewDriver. Nothing in main.go needs to know HealthChecker is an interface with a test-only fake implementation elsewhere — it just needs something that satisfies Healthy() bool, and that's exactly the point of building it this way.

Also notice we only called csi.RegisterIdentityServer — not Controller or Node. That's deliberate. Right now Driver doesn't implement csi.ControllerServer or csi.NodeServer (we haven't written those methods yet), so registering it as those services would fail. We'll add those registrations — and the matching UnimplementedControllerServer / UnimplementedNodeServer embeds in driver.go — in Chapters 5 and 7 as we implement each service.

reflection.Register(server), on the other hand, only needs to be called once, no matter how many CSI services Driver ends up implementing — it inspects whatever's already registered on server and answers questions about all of it. Nothing about this line is CSI-specific either; it's the same one-liner in any Go gRPC server that wants to be inspectable by grpcurl, Postman, or similar tools.

Running it

Since the socket path is /csi/csi.sock, and we're not inside a container yet, we need somewhere in your project directory to stand in for both the socket and the /data directory the health checker writes to. Add a run target to the Makefile from Chapter 2:

run:
	mkdir -p ./csi ./data
	go run ./cmd/localdir-csi

And temporarily change socketPath in main.go to ./csi/csi.sock, and the health checker's Root to ./data, for this local test — we'll switch both back to their container paths (/csi/csi.sock and /data) in Chapter 4 when we deploy for real; inside a pod, those paths won't collide with anything on your laptop.

In one terminal:

make run

You should see:

localdir-csi listening on ./csi/csi.sock

It's just sitting there, waiting. That's correct — a server with nothing to do until someone calls it.

Talking to it

We need a way to make a gRPC call from a terminal, the same way curl lets you make an HTTP call. The tool for that is grpcurl:

go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest

In a second terminal, ask it who it is:

grpcurl -plaintext unix:./csi/csi.sock csi.v1.Identity/GetPluginInfo

The unix: prefix on the target — not a separate -unix flag — is what tells grpcurl (and, underneath it, gRPC's own connection logic) to treat ./csi/csi.sock as a filesystem path rather than a host:port pair. unix: followed directly by a path, with no slashes in between, accepts either a relative or an absolute path exactly as written. If you ever connect to an absolute socket path instead, the equivalent form is three slashes — unix:///absolute/path/to/csi.sock — because the third slash is actually the first character of the path itself.

You should get back:

{
  "name": "localdir.csi.example.com",
  "vendorVersion": "0.1.0"
}

That JSON is grpcurl displaying the GetPluginInfoResponse your method returned — translated from Protobuf binary back into something readable, purely for your benefit as a human. The real caller (a sidecar) would get the actual typed Go struct, not JSON text.

Try the other two:

grpcurl -plaintext unix:./csi/csi.sock csi.v1.Identity/GetPluginCapabilities
grpcurl -plaintext unix:./csi/csi.sock csi.v1.Identity/Probe

GetPluginCapabilities should echo back the CONTROLLER_SERVICE capability you wrote. Probe should return {"ready": true} — because ./data exists (you created it with make run) and LocalDirHealthChecker was able to write and read a file in it. If you delete ./data while the server's running and call Probe again, you should now see {"ready": false} — a real, live demonstration of the exact branch your TestProbe table already covers, now visible over the network instead of just in a test.

If any of these hang instead of returning immediately, double check the socket path matches on both sides — a mistyped path is the single most common mistake at this stage, and it fails silently (grpcurl just can't connect) rather than with an obvious error. A different mistake produces a loud, immediate error instead of a hang: leaving off the unix: prefix — or restoring an old -unix flag from a tutorial you half-remember — makes grpcurl treat the socket path as a host:port address, and it fails right away with something like dial tcp: address ./csi/csi.sock: missing port in address. That error means the target scheme was wrong, not that the server is unreachable.

What just happened, one more time, plainly

You wrote a Go struct with a dependency it doesn't control the implementation of. You told gRPC "this struct answers Identity questions." You started listening on a file (not a network port). A separate tool sent a request to that file, gRPC turned the bytes into a Go function call, your function ran — checking a real directory through an interface your tests had already exercised with a fake — and gRPC turned the return value back into bytes for grpcurl to display.

That entire round trip — socket, gRPC routing, your method, back again — is identical for every RPC you'll write for the rest of this book. Only the method names and what they do will change. Controller's CreateVolume and Node's NodePublishVolume arrive through this exact same pipe, and we'll write both test-first, the same way, starting in Chapter 5.

What you should have now

  • internal/driver/driver.go with a Driver struct — embedding csi.UnimplementedIdentityServer for forward compatibility — a HealthChecker interface, a constructor that takes one in, and a compile-time check (var _ csi.IdentityServer = (*Driver)(nil)) confirming Driver satisfies csi.IdentityServer
  • internal/driver/identity.go implementing all three Identity methods, each one overriding the embedded stub of the same name
  • internal/driver/health.go with LocalDirHealthChecker, the real implementation used outside tests
  • internal/driver/identity_test.go with passing tests for all three methods, written before their implementations, using t.Context() rather than context.Background()
  • cmd/localdir-csi/main.go starting a gRPC server on a Unix socket, with reflection enabled so grpcurl can inspect it
  • A make test target, and a running driver you've personally called over gRPC and gotten real answers from — including watching Probe flip from ready to not-ready in real time

Next chapter, we stop running this by hand and get it into the kind cluster for real — building the container image, deploying it as a DaemonSet alongside the node-driver-registrar sidecar, and watching kubelet discover it for the first time.