Chapter 9: Testing with csi-sanity

Chapter 8 closed with a promise: check this driver against the CSI spec's own expectations, not just the cases this book happened to think of. This chapter keeps that promise.

The tool is csi-sanity. It's the CSI project's own conformance suite — not written by this book, not written by anyone who has ever seen localdir-csi's source code. It only knows the spec. That's exactly what makes it worth running.

What csi-sanity actually checks

Every test this book has written so far was written by this book. That matters, and it's a real limit. A test only catches what its author thought to check. If Chapter 7 never imagined a case, Chapter 7 never tested it.

csi-sanity doesn't have that limit, or at least not the same one. It's built from the CSI spec's own rules — the same spec text this book has quoted directly, chapter after chapter, when explaining why a piece of code works the way it does. csi-sanity turns those rules into real gRPC calls, made against a real running driver, and checks the responses the same way the spec says a CO would.

Say it plainly: this is the first testing in this book that doesn't come from this book. Everything before now checked localdir-csi against its own author's assumptions. This chapter checks it against the spec directly.

A driver process, with nothing else running

csi-sanity doesn't know what Kubernetes is. It has no kubectl. It never reads a manifest. It does exactly one thing: dial a gRPC endpoint, and start making CSI calls against it.

That's a smaller ask than it sounds. It's also a smaller ask than everything Chapter 4 through Chapter 8 built. No kind cluster. No sidecars. No Deployment, no DaemonSet, no ServiceAccount. Just the localdir-csi binary, running somewhere, with a socket for csi-sanity to call.

Every real CSI driver's own test suite works exactly this way. Look at csi-driver-nfs's test/sanity/run-test.sh, or csi-driver-smb's near-identical script: both start the plugin as a bare local process, point csi-sanity at its Unix socket, and run the suite — no cluster in sight. csi-test's own hack/e2e.sh does the same thing against hostpathplugin, the project's reference driver. aws-ebs-csi-driver goes one step further and skips the separate binary entirely — it imports csi-sanity's test package straight into a native Go test and runs the driver in the same process, over an in-memory socket. Different levels of ceremony, same underlying idea: run the driver, alone, and point the conformance suite at it directly.

This book already has a way to do exactly that. It's been sitting in the Makefile since Chapter 3, unused since Chapter 4:

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

Chapter 3 used this to prove GetPluginInfo worked, months before this book ever created a kind cluster. Chapter 4 then pointed main.go's defaults back at the container paths — /csi/csi.sock and /data — because that's what a real pod needs. Running make run unmodified today tries to listen on /csi/csi.sock, an absolute path that doesn't exist, and normally shouldn't exist, on a laptop outside a container.

The fix is the same one Chapter 3 used by hand, now done through the flags Chapter 4 added instead of by editing main.go again. Add this target to the Makefile, alongside run:

sanity-run:
	mkdir -p ./csi ./data
	go run ./cmd/localdir-csi \
		-endpoint unix://$(CURDIR)/csi/csi.sock \
		-data-dir $(CURDIR)/data

Same driver. Same code, unmodified. Just pointed at a socket and a directory this book's Chapter 4 self isn't using for anything else.

One socket, not two

A real production driver usually ships as two separate deployments — one Controller, one Node — sometimes even as two separate binaries. csi-sanity expects that split by default: --csi.endpoint for Identity and Node, --csi.controllerendpoint for Controller, two flags for two different sockets.

localdir-csi doesn't have that split, and Chapter 7 already said why. The Controller Deployment and the Node DaemonSet run "this exact same binary, unmodified, main.go and all. What differs between them isn't the code; it's which container each one runs in, and which sidecar sits next to it." One binary. One socket. All three services, in every pod that runs it — Controller and Node alike.

That single-socket choice means csi-sanity only needs one flag here, not two:

csi-sanity --csi.endpoint=unix://$(pwd)/csi/csi.sock

Leave --csi.controllerendpoint unset, and csi-sanity sends Controller calls to the same endpoint as everything else — which is exactly where they're already being handled, on the exact same socket sanity-run just opened.

Installing csi-sanity

go install github.com/kubernetes-csi/csi-test/v5/cmd/csi-sanity@v5.5.0

There's no prebuilt binary to download — csi-test only ships source, so go install is the only real path. It needs Go 1.25 or newer; this project's own go.mod already asks for Go 1.26, so nothing extra to install there. v5.5.0 pins CSI spec v1.12.0 internally, one point release behind the v1.13.0 this project's own go.mod already depends on. That gap is normal, not a bug to chase down — spec point releases add fields, they don't remove the ones csi-sanity already knows how to check.

What csi-sanity would find, right now, unchanged

Before running it for real, it's worth reasoning through what it will actually do — because most of it comes down to one mechanism, repeated over and over: csi-sanity asks first, checks second.

Before testing any optional RPC, csi-sanity calls ControllerGetCapabilities or NodeGetCapabilities and checks whether this driver claims to support it. Claim it, and the matching tests run for real. Don't claim it, and csi-sanity skips those tests outright — not a failure, a deliberate no-op, logged as Skip.

localdir-csi only ever claims one capability: CREATE_DELETE_VOLUME, from ControllerGetCapabilities, wired up back in Chapter 7. NodeGetCapabilities claims nothing at all — an empty list, on purpose, since Chapter 6. Ask first, checks second: with that capability list, almost the entire suite is going to skip. GetCapacity skips. ListVolumes skips. Every snapshot RPC skips. ExpandVolume, both Controller and Node sides, skips. NodeStageVolume and NodeUnstageVolume skip. And — worth calling out directly, since Chapter 8 spent an entire section explaining why — ControllerPublishVolume and ControllerUnpublishVolume skip too, even though both are real, tested, working code. csi-sanity never looks at the code. It only looks at the capability list, and this driver's capability list never mentions PUBLISH_UNPUBLISH_VOLUME. Implementing an RPC and advertising it are two separate facts. csi-sanity only ever checks the second one.

One RPC breaks that pattern completely: ValidateVolumeCapabilities. It's not gated behind any capability flag, on either side. The spec marks it mandatory, full stop, so csi-sanity calls it no matter what ControllerGetCapabilities said. Every other optional RPC gets a graceful Skip when unsupported. ValidateVolumeCapabilities doesn't get that option.

And right now, at the end of Chapter 8, ValidateVolumeCapabilities doesn't exist. Driver embeds csi.UnimplementedControllerServer, the same way it has since Chapter 7, and that embedded type's ValidateVolumeCapabilities method does exactly one thing: return codes.Unimplemented. csi-sanity expects InvalidArgument, or a successful confirmed response, or NotFound, depending on which case it's exercising. It never expects Unimplemented. Run csi-sanity against this driver today, and this is the one place it wouldn't skip and wouldn't pass. It would fail, honestly and correctly, because the code really doesn't do what the spec says it must.

That's this chapter's real work: close that one gap, the same test-first way every other RPC in this book got built.

What a volume capability actually is

Before the code, the concept. A VolumeCapability is really just two questions, bundled into one struct.

The first question: mount, or block? A Mount capability wants a filesystem — somewhere a pod can read and write ordinary files, the way localdir-csi has worked since Chapter 4. A Block capability wants a raw block device instead, handed to the pod with no filesystem on it at all. Some databases ask for that, for the extra speed. localdir-csi never will. mountCapability(), the test helper this book has reused since Chapter 6, only ever builds the Mount kind — that's not an accident, it's this driver's whole scope.

The second question: who gets to use the volume, and how? That's the AccessMode. SINGLE_NODE_WRITER is the ordinary case — one node, read and write. MULTI_NODE_MULTI_WRITER is the less ordinary one — many nodes at once, all writing, the kind of promise a shared NFS-backed volume can make. The spec defines a few more besides, mostly narrower versions of the same idea, like read-only or single-node-only.

Put the two questions together, and a VolumeCapability boils down to one plain-language ask: "can this volume be used this way?" ValidateVolumeCapabilities is the RPC whose entire job is answering that ask — the one this chapter is about to build.

Implementing ValidateVolumeCapabilities, test-first

ValidateVolumeCapabilitiesRequest carries a volume_id and a list of volume_capabilities — both required. The response carries a Confirmed, present only when every requested capability is one this driver actually supports, plus an optional Message explaining why not, for when it isn't.

Two files again, the same two this book has been building since Chapter 3 and Chapter 7: tests go in internal/driver/controller_test.go, the method itself goes in internal/driver/controller.go. Every code block below names which one it belongs in.

Validation first, same shape as every earlier RPC in this book. Add this test to internal/driver/controller_test.go:

func TestValidateVolumeCapabilities_Validation(t *testing.T) {
	cases := []validationCase[*csi.ValidateVolumeCapabilitiesRequest]{
		{
			name: "missing volume id",
			req: &csi.ValidateVolumeCapabilitiesRequest{
				VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
			},
		},
		{
			name: "missing volume capabilities",
			req: &csi.ValidateVolumeCapabilitiesRequest{
				VolumeId: "vol-1",
			},
		},
	}
	runValidation(t, cases, newTestDriverInTempDir,
		func(t *testing.T, d *Driver, req *csi.ValidateVolumeCapabilitiesRequest) error {
			_, err := d.ValidateVolumeCapabilities(t.Context(), req)
			return err
		},
	)
}

Same validationCase[T]/runValidation pair every validation table since Chapter 6 has used — nothing new to wire up, just another RPC whose required-field checks fit the shape they were built for.

--- FAIL: TestValidateVolumeCapabilities_Validation (0.00s)
    --- FAIL: TestValidateVolumeCapabilities_Validation/missing_volume_id (0.00s)
        testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId: VolumeCapabilities:[0xc00000e5e8]})
    --- FAIL: TestValidateVolumeCapabilities_Validation/missing_volume_capabilities (0.00s)
        testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId:vol-1 VolumeCapabilities:[]})
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.007s
FAIL

Unimplemented — exactly the code a real csi-sanity run against the promoted stub would see too, for the same reason: nothing here validates its arguments yet, it just happens to fail with a more specific code than "missing" would. And once again, both failures land on testing_test.go:85, not on this test's own call to runValidation — the same t.Helper() mechanic Chapters 6 through 8 already established.

Minimal fix, same shallow required-field checks every other RPC in this book already uses. Add the method to internal/driver/controller.go:

func (d *Driver) ValidateVolumeCapabilities(
	ctx context.Context,
	req *csi.ValidateVolumeCapabilitiesRequest,
) (*csi.ValidateVolumeCapabilitiesResponse, error) {
	if req.GetVolumeId() == "" {
		return nil, status.Error(codes.InvalidArgument, "volume_id is required")
	}
	if len(req.GetVolumeCapabilities()) == 0 {
		return nil, status.Error(codes.InvalidArgument, "volume_capabilities is required")
	}

	return &csi.ValidateVolumeCapabilitiesResponse{}, nil
}
=== RUN   TestValidateVolumeCapabilities_Validation
--- PASS: TestValidateVolumeCapabilities_Validation (0.00s)

Green. Next question, same one ControllerPublishVolume already answered in Chapter 8: what about a volume_id that's well-formed, but names nothing real? Next test, still in internal/driver/controller_test.go:

func TestValidateVolumeCapabilities_VolumeNotFound(t *testing.T) {
	d := newTestDriverInTempDir(t)

	req := &csi.ValidateVolumeCapabilitiesRequest{
		VolumeId:           "does-not-exist",
		VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
	}

	_, err := d.ValidateVolumeCapabilities(t.Context(), req)
	requireStatusCode(t, err, codes.NotFound, req)
}
--- FAIL: TestValidateVolumeCapabilities_VolumeNotFound (0.00s)
    controller_test.go:296: code = OK, want NotFound (req=&{VolumeId:does-not-exist VolumeCapabilities:[0xc00000e6c0]})
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.007s
FAIL

Red — nothing checks the volume actually exists yet. The fix reaches for the exact same existence check ControllerPublishVolume already uses. Back in internal/driver/controller.go, inside ValidateVolumeCapabilities:

	path := filepath.Join(d.dataDir, req.GetVolumeId())
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return nil, status.Errorf(codes.NotFound, "volume %q not found", req.GetVolumeId())
	} else if err != nil {
		return nil, status.Errorf(codes.Internal, "checking volume %q: %v", req.GetVolumeId(), err)
	}
=== RUN   TestValidateVolumeCapabilities_VolumeNotFound
--- PASS: TestValidateVolumeCapabilities_VolumeNotFound (0.00s)

Green — and worth a short pause here, not a long one. Chapter 8 already named the reason this check works at all: Controller and every Node pod share one physical disk, in this single-node kind cluster, through the same hostPath. That's still true here. It hasn't changed. It's just resurfacing, in a third method now, the same way Chapter 8 flagged it resurfacing in a second.

Now the part this chapter actually exists for: what does "supported" mean, for a capability? Start with the case that should work — a vol-1 that exists, asked about with the exact shape every other test in this book already builds through mountCapability(). Another test, in internal/driver/controller_test.go:

func TestValidateVolumeCapabilities_ConfirmsSupportedCapability(t *testing.T) {
	dataDir := t.TempDir()
	makeVolumeDir(t, dataDir, "vol-1")
	d := newTestDriver(t, dataDir, nil)

	req := &csi.ValidateVolumeCapabilitiesRequest{
		VolumeId:           "vol-1",
		VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
	}

	resp, err := d.ValidateVolumeCapabilities(t.Context(), req)
	if err != nil {
		t.Fatalf("ValidateVolumeCapabilities() returned an error: %v", err)
	}
	if resp.Confirmed == nil {
		t.Fatal("Confirmed is nil, want it set for a supported capability")
	}
	if len(resp.Confirmed.VolumeCapabilities) != 1 {
		t.Fatalf("Confirmed.VolumeCapabilities has %d entries, want 1", len(resp.Confirmed.VolumeCapabilities))
	}
}
--- FAIL: TestValidateVolumeCapabilities_ConfirmsSupportedCapability (0.00s)
    controller_test.go:315: Confirmed is nil, want it set for a supported capability
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.008s
FAIL

Red, correctly — the existing code returns an empty response no matter what. Here's the moment worth slowing down for. It's tempting to write the whole real implementation in one pass: echo back Confirmed when a capability is supported, and reject it with a Message when it isn't. Both behaviors, one edit. That's exactly the mistake Chapter 8 already made once, with ControllerUnpublishVolume — write two behaviors before either one has its own red test, and the second behavior never actually gets proven red at all.

So: only the minimum needed to pass this test. Back in internal/driver/controller.go:

	return &csi.ValidateVolumeCapabilitiesResponse{
		Confirmed: &csi.ValidateVolumeCapabilitiesResponse_Confirmed{
			VolumeCapabilities: req.GetVolumeCapabilities(),
		},
	}, nil
=== RUN   TestValidateVolumeCapabilities_ConfirmsSupportedCapability
--- PASS: TestValidateVolumeCapabilities_ConfirmsSupportedCapability (0.00s)

Green — but notice what that implementation actually does: it confirms everything, unconditionally. Nothing has checked whether the capability is one this driver genuinely supports. The next test exists specifically to catch that. Add it to internal/driver/controller_test.go:

This is also the first test in the book that needs a VolumeCapability with anything other than mountCapability()'s own hardcoded SINGLE_NODE_WRITER. Rather than build one by hand inline, split mountCapability() so the access mode is a parameter, in internal/driver/testing_test.go:

func mountCapability() *csi.VolumeCapability {
	return mountCapabilityWithMode(csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER)
}

func mountCapabilityWithMode(mode csi.VolumeCapability_AccessMode_Mode) *csi.VolumeCapability {
	return &csi.VolumeCapability{
		AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}},
		AccessMode: &csi.VolumeCapability_AccessMode{
			Mode: mode,
		},
	}
}

mountCapability() keeps every existing call site working exactly as before — it's now just a one-line wrapper around the more general helper, the same Open/Closed shape Chapter 3 and Chapter 7 both already pointed out elsewhere in this driver: the existing, narrower helper didn't need to change, only grow a sibling.

func TestValidateVolumeCapabilities_RejectsUnsupportedAccessMode(t *testing.T) {
	dataDir := t.TempDir()
	makeVolumeDir(t, dataDir, "vol-1")
	d := newTestDriver(t, dataDir, nil)

	req := &csi.ValidateVolumeCapabilitiesRequest{
		VolumeId: "vol-1",
		VolumeCapabilities: []*csi.VolumeCapability{
			mountCapabilityWithMode(csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER),
		},
	}

	resp, err := d.ValidateVolumeCapabilities(t.Context(), req)
	if err != nil {
		t.Fatalf("ValidateVolumeCapabilities() returned an error: %v", err)
	}
	if resp.Confirmed != nil {
		t.Fatal("Confirmed is set, want nil for an unsupported access mode")
	}
	if resp.Message == "" {
		t.Error("Message is empty, want an explanation of why the capability wasn't confirmed")
	}
}
--- FAIL: TestValidateVolumeCapabilities_RejectsUnsupportedAccessMode (0.00s)
    controller_test.go:340: Confirmed is set, want nil for an unsupported access mode
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.010s
FAIL

Real red, against the "confirm everything" version above — proof the earlier green wasn't quietly covering this case too. Now the actual supported-capability check, the first place in this entire driver that looks at which AccessMode a request names, instead of only checking that one was present at all. Last edit to internal/driver/controller.go for this chapter:

	for _, c := range req.GetVolumeCapabilities() {
		if c.GetMount() == nil {
			return &csi.ValidateVolumeCapabilitiesResponse{
				Message: "only mount volumes are supported, not block volumes",
			}, nil
		}
		if c.GetAccessMode().GetMode() != csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER {
			return &csi.ValidateVolumeCapabilitiesResponse{
				Message: "only SINGLE_NODE_WRITER is supported",
			}, nil
		}
	}
=== RUN   TestValidateVolumeCapabilities_ConfirmsSupportedCapability
--- PASS: TestValidateVolumeCapabilities_ConfirmsSupportedCapability (0.00s)
=== RUN   TestValidateVolumeCapabilities_RejectsUnsupportedAccessMode
--- PASS: TestValidateVolumeCapabilities_RejectsUnsupportedAccessMode (0.00s)

Both green. Chapter 6 flagged this moment a long way back, describing mountCapability()'s SINGLE_NODE_WRITER choice: "AccessMode starts to matter once a real StorageClass and PersistentVolumeClaim can specify one." A StorageClass and PersistentVolumeClaim have existed since Chapter 8. But nothing actually read the access mode a claim requested until just now. This is that moment, several chapters late, arriving through the one RPC the spec never lets a driver skip.

Where does "supported" actually get decided, though? Not the way ControllerGetCapabilities decides RPC support, back in Chapter 7 — that one hands back an explicit list, and csi-sanity checks it before a single test runs. VolumeCapability support has no equivalent list anywhere on Driver. It lives entirely inside the loop just written, inline, in ValidateVolumeCapabilities itself. Nothing else in this driver currently needs to ask the same question, so nothing else needs the rule duplicated. If a later chapter taught CreateVolume to reject an unsupported capability too, not just report on one, that would be the moment to pull this loop out into its own function — something both methods could call, so the rule has one home instead of two copies quietly drifting apart. Right now, one method asks the question, so one method owns the answer.

Notice, too, what's deliberately not supported here. MULTI_NODE_MULTI_WRITER gets rejected, not confirmed — even though, in this book's single-node kind cluster, every node already shares the exact same directory through the exact same hostPath. Multiple pods really could read and write it concurrently right now, and nothing would technically stop them. That's not the same as this driver promising multi-node access, though. A promise like that has to hold up against real, independent nodes — separate machines, separate disks — not just against one kind node's own implementation detail. Nothing in this driver has ever been tested against that harder case, so nothing here claims to support it. Honest scope, not a missed optimization: the same principle Chapter 7 and Chapter 8 both already leaned on when naming this driver's own local-only limits out loud.

Proving it, with grpcurl

Same two-step proof this book has used since Chapter 3: a real call, against a real running process, over the real socket. Create a volume first:

grpcurl -plaintext -unix -d '{
  "name": "sanity-demo",
  "capacity_range": {"required_bytes": 1048576},
  "volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}' ./csi/csi.sock csi.v1.Controller/CreateVolume
{
  "volume": {
    "capacityBytes": "1048576",
    "volumeId": "sanity-demo"
  }
}

Ask whether SINGLE_NODE_WRITER mount access is valid for it:

grpcurl -plaintext -unix -d '{
  "volume_id": "sanity-demo",
  "volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}' ./csi/csi.sock csi.v1.Controller/ValidateVolumeCapabilities
{
  "confirmed": {
    "volumeCapabilities": [{"mount": {}, "accessMode": {"mode": "SINGLE_NODE_WRITER"}}]
  }
}

Confirmed. Now the same call, asking for MULTI_NODE_MULTI_WRITER instead:

grpcurl -plaintext -unix -d '{
  "volume_id": "sanity-demo",
  "volume_capabilities": [{"mount": {}, "access_mode": {"mode": "MULTI_NODE_MULTI_WRITER"}}]
}' ./csi/csi.sock csi.v1.Controller/ValidateVolumeCapabilities
{
  "message": "only SINGLE_NODE_WRITER is supported"
}

No confirmed field at all, and — worth noticing directly — no error either. 0 OK, an empty-ish success response with a message explaining why. That's the spec's actual contract for this exact case, not a shortcut this driver invented: an unsupported capability is a normal, well-formed answer, not a failure.

Running csi-sanity for real

One thing to get right before starting either terminal. NodePublishVolume performs a real Linux bind mount — the exact syscall Chapter 6 needed privileged: true for, inside the kind node. Outside a pod, running as a bare process on a laptop, that same syscall still needs root. Skip this, and the Node-service half of the suite fails with mount: ... must be superuser to use mount — a permissions problem, not a driver bug. Say it plainly: the driver is fine, the process just isn't allowed to mount anything yet.

Root has to cover both terminals, not just one. The driver's Unix socket gets created by whichever user starts the driver. Start it with sudo, and the socket file ends up owned by root, with no write permission for anyone else. Connecting to a Unix socket needs write permission on that file. Run csi-sanity afterward as an ordinary user, and the connection gets refused — not because the driver rejected the call, but because the operating system never let the call reach the socket at all. The fix is symmetric: root creates the socket, so root has to be the one dialing it too.

One more wrinkle, and it's sudo's, not this driver's. sudo resets PATH by default, to a short, fixed list of system directories. go and csi-sanity almost never live in that list — they live wherever go install put them, under the calling user's own home directory. Run sudo go run ... or sudo csi-sanity ... directly, and root's shell answers command not found, even though the exact same command works fine one line earlier without sudo. env "PATH=$PATH" fixes that: it hands root's shell the calling user's own PATH, so the same go and csi-sanity root just failed to find are the first ones it finds.

Two terminals, the same pattern Chapter 3 started. First terminal, run as root, with the calling user's own PATH carried along:

sudo env "PATH=$PATH" make sanity-run
localdir-csi listening on /home/you/localdir-csi/csi/csi.sock

Second terminal, root again, for the same reason — the socket only accepts a caller with write permission on it, and that means root on both sides:

sudo env "PATH=$PATH" csi-sanity --csi.endpoint=unix://$(pwd)/csi/csi.sock

csi-sanity prints a running Ginkgo log as it works through the suite, one Describe block at a time, then a summary at the end. The shape of that summary follows directly from everything reasoned through above — worth restating plainly, since it's the whole point of this chapter:

Skipped, because ControllerGetCapabilities never claims them: GetCapacity, ListVolumes, every CreateSnapshot/ListSnapshots Describe block, ControllerExpandVolume, ControllerGetVolumeHealth and ControllerListVolumeHealth, ModifyVolume — and, still, even now, ControllerPublishVolume/ControllerUnpublishVolume, exactly as Chapter 8 predicted. Real code, real tests, real grpcurl proof behind both — none of it visible to csi-sanity, because PUBLISH_UNPUBLISH_VOLUME was never added to the capability list, on purpose, for the reasons Chapter 8 spent a whole section defending.

Skipped, because NodeGetCapabilities claims nothing at all: NodeStageVolume, NodeUnstageVolume, NodeExpandVolume, NodeGetVolumeStats, both node health RPCs.

Passing, because they're both implemented and advertised: DeleteVolume, ControllerGetCapabilities, NodeGetCapabilities, NodeGetInfo — plus every purely argument-validation It under NodePublishVolume and NodeUnpublishVolume, since checking a required field doesn't need any capability flag to be true first. Most of CreateVolume passes here too, with one honest exception, named directly below.

And passing now, for the first time, having been the one real failure this chapter set out to fix: every ValidateVolumeCapabilities case. Required-field checks, the not-found volume, the confirmed capability, the rejected one — all four, exercised by a test suite that has never seen this driver's own source code, agreeing with the driver's own unit tests written a few pages back.

Three gaps are worth naming honestly, not glossing past.

First, and different in kind from the other two — this one is a real driver gap, not a suite limitation. CreateVolume's idempotency check never looks at capacity. Call it twice for the same name, the second time asking for a different size, and the spec says that MUST fail with ALREADY_EXISTS. This driver's CreateVolume doesn't check whether a volume by that name already exists at all before creating one — it just runs os.MkdirAll, harmless the second time since the directory's already there, and returns whatever capacity this request asked for, not whatever the first one did. csi-sanity says so directly: CreateVolume [It] should fail when requesting to create a volume with already existing name and different capacityExpected an error to have occurred. Got: nil. Chapter 7 already named this honestly, calling CreateVolume's idempotency "narrower than the full spec." This is that same gap, now confirmed by a real failing test, not just predicted.

Second: nothing in csi-sanity tests ControllerGetVolume or ControllerModifyVolume at all — not a skip, not a pass, just no test case exists for either one in this version of the suite.

Third: exact It counts and descriptions can shift a little release to release, since csi-test is still an actively maintained project — the reasoning above is what determines the shape of the result, and that shape is what's worth trusting, more than any single number.

Closing the capacity gap, for real

The first gap above isn't staying open. Chapter 6 already showed what closing a gap like this looks like, for NodePublishVolume's access-mode conflict — a metadata-only "does this exist" check can't answer "does this exist as this." CreateVolume gets the same treatment now, with a real failing csi-sanity case to prove the fix against, not just a suspicion.

Add the case that exposes it to internal/driver/controller_test.go, right after TestCreateVolume_Idempotent:

func TestCreateVolume_RejectsCapacityMismatch(t *testing.T) {
	dataDir := t.TempDir()
	d := newTestDriver(t, dataDir, nil)

	first := &csi.CreateVolumeRequest{
		Name:               "vol-1",
		CapacityRange:      &csi.CapacityRange{RequiredBytes: 1 << 20},
		VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
	}
	if _, err := d.CreateVolume(t.Context(), first); err != nil {
		t.Fatalf("first CreateVolume() returned an error: %v", err)
	}

	second := &csi.CreateVolumeRequest{
		Name:               "vol-1",
		CapacityRange:      &csi.CapacityRange{RequiredBytes: 2 << 20},
		VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
	}
	_, err := d.CreateVolume(t.Context(), second)
	requireStatusCode(t, err, codes.AlreadyExists, second)
}
go test ./internal/driver/...

Red — the exact shape csi-sanity just reported, now reproduced in a plain unit test:

=== RUN   TestCreateVolume_RejectsCapacityMismatch
    controller_test.go:125: code = OK, want AlreadyExists (req=&{Name:vol-1 CapacityRange:0xc0000a4170 VolumeCapabilities:[0xc0000da090] Parameters:map[] Secrets:map[] VolumeContentSource:<nil> AccessibilityRequirements:<nil>})
--- FAIL: TestCreateVolume_RejectsCapacityMismatch (0.00s)
FAIL
FAIL	github.com/yourname/localdir-csi/internal/driver	0.009s
FAIL

code = OK — a second, larger request for the same volume name just gets waved through. os.MkdirAll succeeds whether the directory already exists or not, so nothing in the current code has any way to notice a conflict — the same root cause, and the same fix, Chapter 6 already used for NodePublishVolume. Give CreateVolume its own metadata file, in internal/driver/controller.go: first the metadata type and its two helpers, matching nodePublishMeta's shape from Chapter 6 —

// volumeMetaPath is where CreateVolume records the capacity a volume was
// created with, so a later CreateVolume call for the same name can tell a
// genuine idempotent retry (same capacity) apart from a real conflict
// (different capacity).
func volumeMetaPath(dataDir, volumeID string) string {
	return filepath.Join(dataDir, ".csi-meta", "volumes", volumeID+".json")
}

// volumeMeta is the metadata CreateVolume records for each volume it
// creates.
type volumeMeta struct {
	CapacityBytes int64 `json:"capacity_bytes"`
}

func writeVolumeMeta(dataDir, volumeID string, meta volumeMeta) error {
	metaBytes, err := json.Marshal(meta)
	if err != nil {
		return err
	}
	metaPath := volumeMetaPath(dataDir, volumeID)
	if err := os.MkdirAll(filepath.Dir(metaPath), 0o750); err != nil {
		return err
	}
	return os.WriteFile(metaPath, metaBytes, 0o640)
}

func readVolumeMeta(dataDir, volumeID string) (volumeMeta, error) {
	var meta volumeMeta
	data, err := os.ReadFile(volumeMetaPath(dataDir, volumeID))
	if err != nil {
		return meta, err
	}
	err = json.Unmarshal(data, &meta)
	return meta, err
}

Add "encoding/json" to controller.go's imports if it isn't there yet. Then CreateVolume itself, replacing its old body:

func (d *Driver) CreateVolume(
	ctx context.Context,
	req *csi.CreateVolumeRequest,
) (*csi.CreateVolumeResponse, error) {
	if req.GetName() == "" {
		return nil, status.Error(codes.InvalidArgument, "name is required")
	}
	if len(req.GetVolumeCapabilities()) == 0 {
		return nil, status.Error(codes.InvalidArgument, "volume_capabilities is required")
	}

	requestedBytes := req.GetCapacityRange().GetRequiredBytes()

	if existing, err := readVolumeMeta(d.dataDir, req.GetName()); err == nil {
		if existing.CapacityBytes != requestedBytes {
			return nil, status.Errorf(codes.AlreadyExists, "volume %q already exists with a different capacity", req.GetName())
		}
		// Same name, same capacity: a genuine retry. Return the recorded
		// volume instead of re-running os.MkdirAll — MkdirAll would
		// succeed either way, silently papering over a real conflict the
		// way it did before this check existed.
		return &csi.CreateVolumeResponse{
			Volume: &csi.Volume{
				VolumeId:      req.GetName(),
				CapacityBytes: existing.CapacityBytes,
			},
		}, nil
	} else if !os.IsNotExist(err) {
		return nil, status.Errorf(codes.Internal, "reading volume %q metadata: %v", req.GetName(), err)
	}

	path := filepath.Join(d.dataDir, req.GetName())
	if err := os.MkdirAll(path, 0o750); err != nil {
		return nil, status.Errorf(codes.Internal, "creating volume %q: %v", req.GetName(), err)
	}

	meta := volumeMeta{CapacityBytes: requestedBytes}
	if err := writeVolumeMeta(d.dataDir, req.GetName(), meta); err != nil {
		return nil, status.Errorf(codes.Internal, "recording volume %q capacity: %v", req.GetName(), err)
	}

	return &csi.CreateVolumeResponse{
		Volume: &csi.Volume{
			VolumeId:      req.GetName(),
			CapacityBytes: meta.CapacityBytes,
		},
	}, nil
}

DeleteVolume needs one more line, in the same file, cleaning up the new metadata file the same way NodeUnpublishVolume already cleans up nodePublishMeta:

	if err := os.Remove(volumeMetaPath(d.dataDir, req.GetVolumeId())); err != nil && !os.IsNotExist(err) {
		return nil, status.Errorf(codes.Internal, "deleting volume %q metadata: %v", req.GetVolumeId(), err)
	}

That goes right after the existing os.RemoveAll(path) call, before DeleteVolume's final return.

go test ./internal/driver/...
=== RUN   TestCreateVolume_RejectsCapacityMismatch
--- PASS: TestCreateVolume_RejectsCapacityMismatch (0.00s)

Green — and TestCreateVolume_Idempotent, the test that's been there since Chapter 7, still passes too. It always retried with the same capacity, so it never exercised this gap; it just never proved this code was right. A passing idempotency test only proves what it actually retries with — worth remembering the next time a test in this book claims something "works" without saying exactly what it tried.

What you should have now

  • internal/driver/controller.go: a real ValidateVolumeCapabilities, built the same one-test-at-a-time way as every other RPC in this book, including one genuine TDD misstep — two behaviors written before either had its own red test — caught by writing the second test anyway and watching it prove the gap was real
  • The first code in this driver that actually inspects an AccessMode value, rather than only checking that one was present
  • A deliberately narrow definition of "supported": Mount volumes, SINGLE_NODE_WRITER only — honest about what's never been tested, not just about what's technically possible on one shared kind node
  • Makefile's sanity-run target: this driver, running alone, with no Kubernetes anywhere in sight — the same bare-process pattern every real CSI driver's own CI already uses to run this exact suite
  • A real csi-sanity run against a real socket, whose skip/pass/fail shape now matches, case for case, everything Chapters 7 and 8 already decided and explained about this driver's own scope
  • CreateVolume's own metadata file (volumeMeta, writeVolumeMeta, readVolumeMeta), closing the exact gap Chapter 7 named and this chapter's own csi-sanity run confirmed for real — the second use of the sidecar-metadata pattern Chapter 6 started

Two honest gaps remain, both already named directly above: ControllerPublishVolume/ControllerUnpublishVolume stay invisible to this suite, by choice; and ControllerGetVolume/ControllerModifyVolume stay untested by it entirely, not by choice. CreateVolume's capacity gap doesn't join them — it's closed, right above. Chapter 10 turns to snapshots — CreateSnapshot, DeleteSnapshot, and the external-snapshotter sidecar this book hasn't needed until now.