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 capacity —
Expected 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 realValidateVolumeCapabilities, 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
AccessModevalue, rather than only checking that one was present - A deliberately narrow definition of "supported":
Mountvolumes,SINGLE_NODE_WRITERonly — honest about what's never been tested, not just about what's technically possible on one sharedkindnode Makefile'ssanity-runtarget: 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-sanityrun 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 owncsi-sanityrun 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.