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
NewDriveras 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. fakeHealthCheckeris 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 becauseDriveronly ever asked for something with aHealthy() boolmethod, 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 innert— the one belonging to the subtest created byt.Run, not the outer one belonging toTestProbeitself. 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.gowith aDriverstruct — embeddingcsi.UnimplementedIdentityServerfor forward compatibility — aHealthCheckerinterface, a constructor that takes one in, and a compile-time check (var _ csi.IdentityServer = (*Driver)(nil)) confirmingDriversatisfiescsi.IdentityServerinternal/driver/identity.goimplementing all three Identity methods, each one overriding the embedded stub of the same nameinternal/driver/health.gowithLocalDirHealthChecker, the real implementation used outside testsinternal/driver/identity_test.gowith passing tests for all three methods, written before their implementations, usingt.Context()rather thancontext.Background()cmd/localdir-csi/main.gostarting a gRPC server on a Unix socket, with reflection enabled sogrpcurlcan inspect it- A
make testtarget, and a running driver you've personally called over gRPC and gotten real answers from — including watchingProbeflip 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.