Chapter 6: The Node Service, Part 2
NodeGetInfo and NodeGetCapabilities answer questions. Neither one
touches a filesystem, mounts anything, or changes what a pod can actually
see. This chapter writes the two methods that do: NodePublishVolume and
NodeUnpublishVolume — the calls that turn a directory of data sitting on
a node's disk into something a container can read and write through, and
then, later, take that access away again cleanly. This is the payoff
Chapter 5 pointed at, and it's also where mount --bind, first mentioned
back in Chapter 2, stops being a phrase in a sidebar and becomes something
your own code calls.
What "publish" actually means here
A bind mount is a Linux feature that makes one directory also appear
at a second path — not a copy, the same files, reachable through two
different doors. If /var/lib/localdir-csi/data/vol-1 holds a volume's
real data, bind-mounting it onto some other path makes that second path
behave exactly like the first: read a file through either one, and you're
reading the same bytes.
That's exactly the tool NodePublishVolume needs. Kubernetes decides
where a container should see its volume — a path deep inside kubelet's
own directory structure, specific to one pod, that your driver doesn't
get to choose — and hands you that path as target_path. Your volume's
actual data already lives somewhere else, at a location only your driver
knows about. A bind mount is how you make the data at your location
appear, unmodified, at Kubernetes's location. "Publish" means "make this
volume visible at the path you were told to use." "Unpublish" means undo
exactly that, leaving the underlying data untouched.
One naming note before the code: the CSI spec itself, and its own generated Go doc comments, refer to whatever's driving these calls as the CO — short for Container Orchestrator. Kubernetes is the only CO this book ever talks to, but the spec is deliberately written so a driver like this one could just as easily plug into a different orchestrator without changing a line of RPC code, which is why "CO" shows up instead of "Kubernetes" in the spec's own text, and in this chapter's code comments.
Chapter 2 already flagged why this can't be tested by just running things
on your own laptop: kind nodes are real Linux containers, so when
localdir-csi calls mount --bind inside one, it's genuinely mounting
something, inside that container's own filesystem — the same kind of
operation, on the same kind of system, that would happen on a real cloud
VM. That's also why this chapter's hands-on testing happens by reaching
into a kind node directly, the way Chapter 2 said it eventually would.
A narrow interface for the one thing we need: Mounter
Performing a real bind mount from Go means calling into k8s.io/mount-utils,
the same library Kubernetes's own CSI drivers use — it wraps the
underlying mount/umount system calls and the bookkeeping needed to ask
"is this path already a mount point?" correctly on Linux. Its Interface
type exposes close to a dozen methods (variations on Mount for
different flag combinations, List, GetMountRefs, and more). Driver
needs exactly three of them: mount something, unmount something, and ask
whether a path is currently a mount point.
Rather than depending on the whole real interface, declare a smaller one that says exactly that:
type Mounter interface {
Mount(source, target, fsType string, options []string) error
Unmount(target string) error
IsMountPoint(target string) (bool, error)
}
This is the Interface Segregation Principle, the same family of idea
as HealthChecker from Chapter 3: depend on the smallest interface that
describes what you actually need, not the largest one a library happens
to offer. The payoff here is concrete, not just tidy — because Go decides
whether a type satisfies an interface by comparing method signatures, not
by any explicit "implements" declaration, the real value k8s.io/mount-utils
hands back already has a Mount, an Unmount, and an IsMountPoint with
these exact signatures. It satisfies Mounter automatically. No adapter,
no wrapper type, no glue code — declaring the interface Driver actually
needs is the entire integration.
Driver gains a data directory and a mounter
NodePublishVolume needs to know two new things: where on this node
volumes actually live, and something that can perform a mount. Update
internal/driver/driver.go:
package driver
import (
"github.com/container-storage-interface/spec/lib/go/csi"
)
// HealthChecker describes anything that can report whether the driver's
// backend is usable right now. Identity's Probe method depends on this
// interface — not on any specific way of checking health — so that a
// real filesystem check and a test fake are equally valid things to hand
// it. This is the whole idea behind Dependency Inversion: Driver names
// what it needs, not how that need gets satisfied.
type HealthChecker interface {
Healthy() bool
}
// Mounter describes the three mount operations Driver actually performs.
// A real *mount-utils mounter satisfies this automatically; tests hand it
// a fake that never touches a real filesystem.
type Mounter interface {
Mount(source, target, fsType string, options []string) error
Unmount(target string) error
IsMountPoint(target string) (bool, error)
}
// Driver holds everything our CSI driver needs to answer gRPC calls.
type Driver struct {
csi.UnimplementedIdentityServer
csi.UnimplementedNodeServer
name string
version string
health HealthChecker
nodeID string
dataDir string
mount Mounter
}
// NewDriver builds a Driver. health and mount can be nil — health is
// guarded in Probe, and mount is only touched by RPCs that need it —
// which is useful for tests that don't care about either, but main.go
// always passes real implementations of both.
func NewDriver(name, version string, health HealthChecker, nodeID, dataDir string, mount Mounter) *Driver {
return &Driver{
name: name,
version: version,
health: health,
nodeID: nodeID,
dataDir: dataDir,
mount: mount,
}
}
// Compile-time checks: these lines do nothing at runtime. They exist
// purely so that if you ever change Driver in a way that breaks either
// interface, the build fails immediately with a clear error — instead of
// failing later, mysteriously, when gRPC tries to register your service.
var _ csi.IdentityServer = (*Driver)(nil)
var _ csi.NodeServer = (*Driver)(nil)
NewDriver grew two more parameters, and — the same lesson every
constructor change in this book has taught — that breaks every existing
call site at once. Run the tests:
go test ./internal/driver/...
# github.com/yourname/localdir-csi/internal/driver [github.com/yourname/localdir-csi/internal/driver.test]
internal/driver/identity_test.go:10:59: not enough arguments in call to NewDriver
have (string, string, nil, string)
want (string, string, HealthChecker, string, string, Mounter)
internal/driver/identity_test.go:26:59: not enough arguments in call to NewDriver
have (string, string, nil, string)
want (string, string, HealthChecker, string, string, Mounter)
internal/driver/identity_test.go:66:97: not enough arguments in call to NewDriver
have (string, string, *fakeHealthChecker, string)
want (string, string, HealthChecker, string, string, Mounter)
internal/driver/node_test.go:10:59: not enough arguments in call to NewDriver
have (string, string, nil, string)
want (string, string, HealthChecker, string, string, Mounter)
internal/driver/node_test.go:23:59: not enough arguments in call to NewDriver
have (string, string, nil, string)
want (string, string, HealthChecker, string, string, Mounter)
FAIL github.com/yourname/localdir-csi/internal/driver [build failed]
FAIL
None of these five existing tests care about a data directory or a
mounter — Identity's tests never touch either, and NodeGetInfo/
NodeGetCapabilities only ever read nodeID. The smallest honest fix
would be a placeholder for each — an empty string for dataDir, nil
for mount — the same way nil already stands in for a health checker
these particular tests don't need. But this is the third time NewDriver
has grown a parameter and broken every call site at once, and this
chapter is about to add a dozen more tests that all need to build a
Driver the same way. Patching five call sites by hand, again, is the
smallest fix for today; it isn't the smallest fix for the pattern.
Create internal/driver/testing_test.go — a file with no tests of its
own, only shared scaffolding every other test file in this package can
call:
package driver
import (
"testing"
)
// newTestDriver builds a Driver wired to the package's shared test
// constants, with the given dataDir and mount. Pass "" for dataDir when
// a test doesn't need one, and nil for mount when the call path never
// reaches the mounter.
func newTestDriver(t *testing.T, dataDir string, mount Mounter) *Driver {
t.Helper()
return NewDriver(name, version, nil, nodeID, dataDir, mount)
}
// newTestDriverInTempDir is the common case: a fresh temp dataDir and no
// mounter. Each call gets its own t.TempDir(), so tests stay isolated
// from each other.
func newTestDriverInTempDir(t *testing.T) *Driver {
t.Helper()
return newTestDriver(t, t.TempDir(), nil)
}
newTestDriver hardcodes health to nil — every test that actually
needs to control health, like TestProbe, still calls NewDriver
directly, exactly as before. What it does fix is the other five
parameters: name, version, and nodeID are always the same three
values across this entire package, and dataDir/mount are the two
that actually vary test to test. t.Helper() matters here for the same
reason it always does — a future failure inside a test built this way
should point at that test's own line, not at a line inside this helper.
name, version, and nodeID need to actually exist somewhere now.
Add this to internal/driver/identity_test.go, alongside its existing
imports:
const (
name = "localdir.csi.example.com"
version = "0.1.0"
nodeID = "test-node-1"
dataDir = ""
)
These replace the literal strings scattered across every test file in
this package — the same "localdir.csi.example.com", "0.1.0", and
node ID that TestGetPluginInfo, TestGetPluginCapabilities,
TestProbe, TestNodeGetInfo, and TestNodeGetCapabilities were each
already retyping by hand. Update the five call sites to match: the four
that don't need a real health checker become
d := newTestDriver(t, dataDir, nil)
and TestProbe's, which still needs to hand Driver a real
*fakeHealthChecker, becomes
d := NewDriver(name, version, &fakeHealthChecker{healthy: tt.healthy}, nodeID, dataDir, nil)
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.002s
Green again — and if NewDriver ever grows another parameter, only
newTestDriver itself needs to change. Every test that calls it stays
exactly as it is; this is newTestDriver's whole reason for existing.
Writing NodePublishVolume, test-first
Testing this honestly means never touching a real filesystem mount —
that needs root, and a test suite that only passes as root isn't one you
can trust in CI. Mounter exists precisely so a test can hand Driver a
fake that records what it was asked to do instead of doing it. One test
at a time, same rhythm as every RPC so far: write a test, watch it fail,
write only enough code to pass it, then move to the next case.
Add this to internal/driver/node_test.go, alongside the existing
NodeGetInfo/NodeGetCapabilities tests:
type mountCall struct {
source, target, fsType string
options []string
}
type fakeMounter struct {
mounted bool
isMountPointErr error
mountErr error
unmountErr error
mountCalls []mountCall
unmountCalls []string
}
func (f *fakeMounter) Mount(source, target, fsType string, options []string) error {
f.mountCalls = append(f.mountCalls, mountCall{source, target, fsType, options})
return f.mountErr
}
func (f *fakeMounter) Unmount(target string) error {
f.unmountCalls = append(f.unmountCalls, target)
return f.unmountErr
}
func (f *fakeMounter) IsMountPoint(target string) (bool, error) {
if f.isMountPointErr != nil {
return false, f.isMountPointErr
}
return f.mounted, nil
}
fakeMounter belongs in node_test.go — nothing outside Node's own
tests needs it. The next piece doesn't: every test below needs one
particular *csi.VolumeCapability, and later chapters' Controller tests
will need the exact same one. That makes it shared scaffolding, not
node_test.go's private business — add it to
internal/driver/testing_test.go instead:
func mountCapability() *csi.VolumeCapability {
return &csi.VolumeCapability{
AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{
Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
},
}
}
Add "github.com/container-storage-interface/spec/lib/go/csi" to
testing_test.go's imports.
mountCapability is a test-only helper — nothing in Driver itself calls
it — that builds the one piece of a valid request every test below needs
and none of them should have to construct by hand: a
*csi.VolumeCapability. The CSI spec uses VolumeCapability to describe
how a volume needs to be usable, and it's built from two parts.
AccessType is a choice between two shapes: a Block capability (the
volume shows up as a raw block device, no filesystem at all) or a Mount
capability (the volume shows up as a directory you mount). This driver
only ever deals in directories, so the helper always builds the Mount
variant, and leaves the nested MountVolume value empty — it has fields
for a filesystem type and mount flags, neither of which apply here, since
every mount this driver performs is a plain bind mount rather than a real
filesystem format.
AccessMode describes how many things are allowed to use the volume at
once, and how. SINGLE_NODE_WRITER means one node, read-write — the
simplest case, and the only one NodePublishVolume assumes right now.
The spec defines several other modes, including read-only and
multiple-nodes-at-once variants, but nothing in this chapter's code
inspects AccessMode's actual value yet: the validation check below only
asks whether the VolumeCapability pointer is nil at all, since
volume_capability is a required field of the request regardless of
which mode it names. AccessMode starts to matter once a real
StorageClass and PersistentVolumeClaim can specify one.
This is also the first RPC in the book that needs more than one
invalid-request case checked the same way — and it won't be the last;
NodeUnpublishVolume, a few pages from now, needs the same shape of
test, and so will several Controller methods in Chapter 7. Rather than
writing "build a table, loop over it, assert InvalidArgument" by hand
every time, add two small pieces of shared scaffolding to
internal/driver/testing_test.go:
// requireStatusCode asserts that err carries the given gRPC code. req is
// included in the failure message for debugging.
func requireStatusCode(t *testing.T, err error, want codes.Code, req any) {
t.Helper()
if got := status.Code(err); got != want {
t.Fatalf("code = %v, want %v (req=%+v)", got, want, req)
}
}
// validationCase is one row of a table-driven validation test: a request
// that should be rejected with codes.InvalidArgument.
type validationCase[T any] struct {
name string
req T
}
// runValidation runs a table of validation cases against call. For each
// case it builds a fresh driver via newDriver, invokes call, and asserts
// the error is InvalidArgument. newDriver is called inside the subtest,
// so per-test temp dirs and contexts stay scoped to that subtest.
func runValidation[T any](
t *testing.T,
cases []validationCase[T],
newDriver func(t *testing.T) *Driver,
call func(t *testing.T, d *Driver, req T) error,
) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := newDriver(t)
err := call(t, d, tc.req)
requireStatusCode(t, err, codes.InvalidArgument, tc.req)
})
}
}
Add "google.golang.org/grpc/codes" and "google.golang.org/grpc/status"
to testing_test.go's imports.
requireStatusCode replaces the one-off if status.Code(err) != want { t.Fatalf(...) } block every RPC's tests were about to start repeating.
validationCase/runValidation go one step further: [T any] is a Go
generic — this pair works for *csi.NodePublishVolumeRequest,
*csi.NodeUnpublishVolumeRequest, or any other request type, without
writing a new loop for each. That's the same Open/Closed shape Chapter 3
already applied to a slice of capabilities, applied here to a slice of
test cases instead: a new RPC's validation test becomes a new table and
a two-line call to runValidation, not a new copy of the loop.
With that in place, TestNodePublishVolume_Validation itself gets much
smaller. Add this to internal/driver/node_test.go, alongside the
existing NodeGetInfo/NodeGetCapabilities tests:
func TestNodePublishVolume_Validation(t *testing.T) {
cases := []validationCase[*csi.NodePublishVolumeRequest]{
{
name: "missing volume ID",
req: &csi.NodePublishVolumeRequest{
TargetPath: "/target",
VolumeCapability: mountCapability(),
},
},
{
name: "missing target path",
req: &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
VolumeCapability: mountCapability(),
},
},
{
name: "missing volume capability",
req: &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: "/target",
},
},
}
runValidation(t, cases,
func(t *testing.T) *Driver { return newTestDriver(t, dataDir, &fakeMounter{}) },
func(t *testing.T, d *Driver, req *csi.NodePublishVolumeRequest) error {
_, err := d.NodePublishVolume(t.Context(), req)
return err
},
)
}
This needs google.golang.org/grpc/codes in node_test.go's imports too
— runValidation takes care of status, but node_test.go still names
codes.NotFound and friends directly in tests further down (path/filepath
and os are coming, but not yet).
Run it:
go test ./internal/driver/...
Red — and a different shape of red than any RPC so far.
NodePublishVolume doesn't exist as a method on *Driver yet, so these
calls fall through to the embedded UnimplementedNodeServer's stub,
which returns a real gRPC status error carrying codes.Unimplemented —
not the code this test wants, InvalidArgument, but a real, specific
one all the same, not a generic failure. Notice the failure points at
testing_test.go, not node_test.go — requireStatusCode calls
t.Helper(), but the subtest closure inside runValidation that calls
it doesn't, so Go attributes the failure to the innermost non-helper
frame, which lives in testing_test.go:
--- FAIL: TestNodePublishVolume_Validation (0.00s)
--- FAIL: TestNodePublishVolume_Validation/missing_volume_ID (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId: PublishContext:map[] StagingTargetPath: TargetPath:/target VolumeCapability:0xc00010c1e0 Readonly:false Secrets:map[] VolumeContext:map[]})
--- FAIL: TestNodePublishVolume_Validation/missing_target_path (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId:vol-1 PublishContext:map[] StagingTargetPath: TargetPath: VolumeCapability:0xc00010c1f8 Readonly:false Secrets:map[] VolumeContext:map[]})
--- FAIL: TestNodePublishVolume_Validation/missing_volume_capability (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId:vol-1 PublishContext:map[] StagingTargetPath: TargetPath:/target VolumeCapability:<nil> Readonly:false Secrets:map[] VolumeContext:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
(The exact line number inside testing_test.go, and the exact pointer
address printed for VolumeCapability, will differ slightly depending on
how you've laid the file out — neither one is the point; the code and
the message are.)
Write just enough of NodePublishVolume to fix it. status.Error is new
this chapter — every RPC before now either succeeded or fell through to
UnimplementedNodeServer's stub, never returning a specific failure code
on purpose. The CSI spec ties specific conditions to specific gRPC status
codes (InvalidArgument for a malformed request), and
status.Error(code, message) is how a handler attaches a real one
instead of a generic error. Create internal/driver/node.go's new
method:
func (d *Driver) NodePublishVolume(
ctx context.Context,
req *csi.NodePublishVolumeRequest,
) (*csi.NodePublishVolumeResponse, error) {
if req.GetVolumeId() == "" {
return nil, status.Error(codes.InvalidArgument, "volume_id is required")
}
if req.GetTargetPath() == "" {
return nil, status.Error(codes.InvalidArgument, "target_path is required")
}
if req.GetVolumeCapability() == nil {
return nil, status.Error(codes.InvalidArgument, "volume_capability is required")
}
return &csi.NodePublishVolumeResponse{}, nil
}
That last line is deliberately a stub — the three checks are all this
step needs, and returning success unconditionally afterward is the
smallest thing that makes the current test pass. Add "google.golang.org/grpc/codes"
and "google.golang.org/grpc/status" to node.go's imports too.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Green. Next case: a volume that doesn't exist. Add this test below
TestNodePublishVolume_Validation:
func TestNodePublishVolume_VolumeNotFound(t *testing.T) {
d := newTestDriver(t, t.TempDir(), &fakeMounter{})
req := &csi.NodePublishVolumeRequest{
VolumeId: "does-not-exist",
TargetPath: filepath.Join(t.TempDir(), "target"),
VolumeCapability: mountCapability(),
}
_, err := d.NodePublishVolume(t.Context(), req)
requireStatusCode(t, err, codes.NotFound, req)
}
Add "path/filepath" to node_test.go's imports.
go test ./internal/driver/...
Red — the stub from the last step returns success unconditionally, so
there's no NotFound to find yet. This time requireStatusCode is
called directly from TestNodePublishVolume_VolumeNotFound itself, no
subtest closure in between, so the failure points right back at
node_test.go, at requireStatusCode's own call site:
--- FAIL: TestNodePublishVolume_VolumeNotFound (0.00s)
node_test.go:113: code = OK, want NotFound (req=&{VolumeId:does-not-exist PublishContext:map[] StagingTargetPath: TargetPath:/tmp/TestNodePublishVolume_VolumeNotFound2786629652/002/target VolumeCapability:0xc00010c2e8 Readonly:false Secrets:map[] VolumeContext:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.005s
FAIL
(As ever, the exact temp path and pointer address in your own output will differ from this one.)
Fix it. This is where the source path convention this chapter already
described gets written down in code:
filepath.Join(d.dataDir, req.GetVolumeId()) assumes a volume's data
lives in a directory named after its own ID, directly under the driver's
data root — right now that directory has to exist already, or the
request fails with NotFound; Chapter 7's CreateVolume is what will
make it exist automatically, using this exact same naming rule.
source := filepath.Join(d.dataDir, req.GetVolumeId())
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil, status.Errorf(codes.NotFound, "volume %q not found", req.GetVolumeId())
}
return nil, status.Errorf(codes.Internal, "checking volume %q: %v", req.GetVolumeId(), err)
}
return &csi.NodePublishVolumeResponse{}, nil
That replaces the old stub return — insert it right after the three
validation checks in NodePublishVolume, and add "os" and "path/filepath"
to node.go's imports.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.004s
Green. Next: actually mounting something, for a volume that does exist —
TestNodePublishVolume_MountsTheVolume, added below
TestNodePublishVolume_VolumeNotFound. Setting up "a fake volume
directory" is also something every remaining test in this file, and
every Controller test Chapter 7 adds, needs to do at least once. Add one
more helper to internal/driver/testing_test.go first:
// makeVolumeDir creates a fake volume directory named volName inside
// dataDir and returns its path. Fails the test immediately if the mkdir
// fails.
func makeVolumeDir(t *testing.T, dataDir, volName string) string {
t.Helper()
p := filepath.Join(dataDir, volName)
if err := os.Mkdir(p, 0750); err != nil {
t.Fatalf("creating fake volume dir %q: %v", p, err)
}
return p
}
Add "os" and "path/filepath" to testing_test.go's imports.
func TestNodePublishVolume_MountsTheVolume(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
targetPath := filepath.Join(t.TempDir(), "target")
mounter := &fakeMounter{}
d := newTestDriver(t, dataDir, mounter)
req := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
VolumeCapability: mountCapability(),
}
if _, err := d.NodePublishVolume(t.Context(), req); err != nil {
t.Fatalf("NodePublishVolume() returned an error: %v", err)
}
if _, err := os.Stat(targetPath); err != nil {
t.Fatalf("target path was not created: %v", err)
}
if len(mounter.mountCalls) != 1 {
t.Fatalf("got %d Mount calls, want 1", len(mounter.mountCalls))
}
got := mounter.mountCalls[0]
wantSource := filepath.Join(dataDir, "vol-1")
if got.source != wantSource {
t.Errorf("source = %q, want %q", got.source, wantSource)
}
if got.target != targetPath {
t.Errorf("target = %q, want %q", got.target, targetPath)
}
if len(got.options) != 1 || got.options[0] != "bind" {
t.Errorf("options = %v, want [bind]", got.options)
}
}
Add "os" to node_test.go's imports too.
go test ./internal/driver/...
Red — NodePublishVolume finds the volume now, but still just returns
success without creating the target path or calling Mount at all
(t.TempDir() generates a fresh, uniquely-named directory on every run,
so the exact path in your own output will differ from this one):
--- FAIL: TestNodePublishVolume_MountsTheVolume (0.00s)
node_test.go:135: target path was not created: stat /tmp/TestNodePublishVolume_MountsTheVolume3795384414/002/target: no such file or directory
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
Fix it — the spec makes creating target_path the driver's job, not the
CO's, so NodePublishVolume does that unconditionally before touching
the mounter:
// The spec makes creating target_path our job, not the CO's.
if err := os.MkdirAll(req.GetTargetPath(), 0750); err != nil {
return nil, status.Errorf(codes.Internal, "creating target path: %v", err)
}
if err := d.mount.Mount(source, req.GetTargetPath(), "", []string{"bind"}); err != nil {
return nil, status.Errorf(codes.Internal, "mounting volume %q: %v", req.GetVolumeId(), err)
}
return &csi.NodePublishVolumeResponse{}, nil
Replace the old return &csi.NodePublishVolumeResponse{}, nil that
followed the NotFound check with this.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.004s
Green. Next: honoring readonly. Add this below
TestNodePublishVolume_MountsTheVolume:
func TestNodePublishVolume_Readonly(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
targetPath := filepath.Join(t.TempDir(), "target")
mounter := &fakeMounter{}
d := newTestDriver(t, dataDir, mounter)
req := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
VolumeCapability: mountCapability(),
Readonly: true,
}
if _, err := d.NodePublishVolume(t.Context(), req); err != nil {
t.Fatalf("NodePublishVolume() returned an error: %v", err)
}
got := mounter.mountCalls[0].options
if len(got) != 2 || got[0] != "bind" || got[1] != "ro" {
t.Errorf("options = %v, want [bind ro]", got)
}
}
go test ./internal/driver/...
Red — the mount options are hardcoded to [bind] and never look at
req.GetReadonly():
--- FAIL: TestNodePublishVolume_Readonly (0.00s)
node_test.go:175: options = [bind], want [bind ro]
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
Fix it — build the options slice instead of hardcoding it:
options := []string{"bind"}
if req.GetReadonly() {
options = append(options, "ro")
}
if err := d.mount.Mount(source, req.GetTargetPath(), "", options); err != nil {
return nil, status.Errorf(codes.Internal, "mounting volume %q: %v", req.GetVolumeId(), err)
}
This replaces the previous Mount call, which passed []string{"bind"}
directly.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.005s
Green. One case left for this method: a target that's already published.
Add this below TestNodePublishVolume_Readonly:
func TestNodePublishVolume_AlreadyMounted(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
targetPath := t.TempDir()
mounter := &fakeMounter{mounted: true}
d := newTestDriver(t, dataDir, mounter)
req := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
VolumeCapability: mountCapability(),
}
if _, err := d.NodePublishVolume(t.Context(), req); err != nil {
t.Fatalf("NodePublishVolume() returned an error: %v", err)
}
if len(mounter.mountCalls) != 0 {
t.Errorf("got %d Mount calls, want 0 — already-mounted target must not be remounted", len(mounter.mountCalls))
}
}
go test ./internal/driver/...
Red — nothing so far ever asks the mounter whether the target is already
mounted, so Mount gets called again even though fakeMounter.mounted
is true:
--- FAIL: TestNodePublishVolume_AlreadyMounted (0.00s)
node_test.go:198: got 1 Mount calls, want 0 — already-mounted target must not be remounted
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
Fix it by checking first, and short-circuiting if the answer is yes:
mounted, err := d.mount.IsMountPoint(req.GetTargetPath())
if err != nil {
return nil, status.Errorf(codes.Internal, "checking target path: %v", err)
}
if mounted {
// NodePublishVolume must be idempotent: a CO that isn't sure a
// previous call succeeded is expected to retry it, and retrying
// must not fail or remount.
return &csi.NodePublishVolumeResponse{}, nil
}
Insert this right after the os.MkdirAll call and before building the
options slice.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.005s
Green — and the common case is handled: the exact same request, retried. The comment above is worth pausing on: idempotent means calling an operation more than once, with the same arguments, has the same effect as calling it exactly once — nothing extra happens, and nothing errors out, the second time. That's not incidental here; the CSI spec requires it, because a CO that times out waiting for a response has no way to know whether the call actually succeeded, and the only safe thing for it to do is call again.
That phrase "with the same arguments" is doing more work than it looks like it is.
Writing NodePublishVolume, test-first: telling a retry from a conflict
IsMountPoint(target_path) only ever answers true or false. It
says nothing about what is mounted there, or how. Call
NodePublishVolume once for a volume with readonly: false, then
again for the same volume and target path with readonly: true, and
the check just written waves the second call through anyway —
"already mounted" reads as "safe to report success," full stop,
regardless of whether this is genuinely the same request retried or a
different request that happens to reuse the same volume and target
path. Not a hypothetical, either: two Pods mounting the same PVC with
different access modes would trigger exactly this.
Add the test, right after TestNodePublishVolume_AlreadyMounted in
internal/driver/node_test.go:
func TestNodePublishVolume_RejectsAccessModeMismatch(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
targetPath := filepath.Join(t.TempDir(), "target")
first := newTestDriver(t, dataDir, &fakeMounter{})
firstReq := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
VolumeCapability: mountCapability(),
Readonly: false,
}
if _, err := first.NodePublishVolume(t.Context(), firstReq); err != nil {
t.Fatalf("first NodePublishVolume() returned an error: %v", err)
}
// A second driver instance, same dataDir, mounter pre-set to report
// the target as already mounted — the same signal a CO's retry would
// see from a real kubelet, this time asking for it read-only.
second := newTestDriver(t, dataDir, &fakeMounter{mounted: true})
secondReq := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
VolumeCapability: mountCapability(),
Readonly: true,
}
_, err := second.NodePublishVolume(t.Context(), secondReq)
requireStatusCode(t, err, codes.AlreadyExists, secondReq)
}
go test ./internal/driver/...
Red — nothing anywhere records what a mount was actually made with, so there's nothing to compare the second call against:
=== RUN TestNodePublishVolume_RejectsAccessModeMismatch
node_test.go:229: code = OK, want AlreadyExists (req=&{VolumeId:vol-1 PublishContext:map[] StagingTargetPath: TargetPath:/tmp/TestNodePublishVolume_RejectsAccessModeMismatch4183913702/002/target VolumeCapability:0xc0000d0090 Readonly:true Secrets:map[] VolumeContext:map[]})
--- FAIL: TestNodePublishVolume_RejectsAccessModeMismatch (0.00s)
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.002s
FAIL
Fixing it means answering a question the driver currently has no way
to ask: what was this volume actually published with, last time? A
boolean "is something mounted here" isn't enough — the driver needs
to remember the actual parameters. That means writing something
down: a small metadata file, sitting next to the volume's data but
never inside it, recording what a call actually did so a later call
can compare against that record instead of guessing from a side
effect. Add this to internal/driver/node.go:
// nodePublishMetaPath is where NodePublishVolume records which target path
// and access mode a volume was last published with on this node, so a
// later NodePublishVolume call for the same volume can tell a genuine
// idempotent retry (same target path, same readonly flag) apart from a
// real conflict (a different one).
func nodePublishMetaPath(dataDir, volumeID string) string {
return filepath.Join(dataDir, ".csi-meta", "publish", volumeID+".json")
}
type nodePublishMeta struct {
TargetPath string `json:"target_path"`
Readonly bool `json:"readonly"`
}
func writeNodePublishMeta(dataDir, volumeID string, meta nodePublishMeta) error {
metaBytes, err := json.Marshal(meta)
if err != nil {
return err
}
metaPath := nodePublishMetaPath(dataDir, volumeID)
if err := os.MkdirAll(filepath.Dir(metaPath), 0o750); err != nil {
return err
}
return os.WriteFile(metaPath, metaBytes, 0o640)
}
func readNodePublishMeta(dataDir, volumeID string) (nodePublishMeta, error) {
var meta nodePublishMeta
data, err := os.ReadFile(nodePublishMetaPath(dataDir, volumeID))
if err != nil {
return meta, err
}
err = json.Unmarshal(data, &meta)
return meta, err
}
Add "encoding/json" to node.go's imports. Then change
NodePublishVolume's already-mounted branch, in the same file:
if mounted {
// NodePublishVolume must be idempotent: a CO that isn't sure a
// previous call succeeded is expected to retry it, and retrying
// must not fail or remount. But "already mounted" only means
// "safe to report success" if it was mounted with these same
// parameters — otherwise this is a real conflict, not a retry.
if existing, err := readNodePublishMeta(d.dataDir, req.GetVolumeId()); err == nil {
if existing.TargetPath != req.GetTargetPath() || existing.Readonly != req.GetReadonly() {
return nil, status.Errorf(codes.AlreadyExists, "volume %q is already published with different parameters", req.GetVolumeId())
}
} else if !os.IsNotExist(err) {
return nil, status.Errorf(codes.Internal, "reading volume %q publish metadata: %v", req.GetVolumeId(), err)
}
return &csi.NodePublishVolumeResponse{}, nil
}
And record the metadata right after a real mount succeeds, before
NodePublishVolume's final return:
if err := writeNodePublishMeta(d.dataDir, req.GetVolumeId(), nodePublishMeta{
TargetPath: req.GetTargetPath(),
Readonly: req.GetReadonly(),
}); err != nil {
return nil, status.Errorf(codes.Internal, "recording volume %q publish metadata: %v", req.GetVolumeId(), err)
}
NodeUnpublishVolume, written a little further down this chapter,
gets one more line of its own to clean this metadata file back up —
covered when that method's fix comes up.
go test ./internal/driver/...
=== RUN TestNodePublishVolume_RejectsAccessModeMismatch
--- PASS: TestNodePublishVolume_RejectsAccessModeMismatch (0.00s)
Green — and now NodePublishVolume is done, in the stronger sense
the spec actually asks for. Worth naming the pattern explicitly,
because it's going to come up again: an operation that's supposed to
be idempotent is not automatically safe just because the primitive
underneath it happens not to fail on a repeat call — IsMountPoint
returning true here, but the same shape shows up anywhere a driver
checks "does this already exist" instead of "does this already exist
as this specific thing." "Looks idempotent" and "is idempotent" are
different claims. The first is free. The second costs one metadata
file and one comparison, every time: record what you actually did,
compare what's being asked against what you recorded, and only then
decide whether this is the same request again or a different one
wearing the same name. Keep an eye out for this shape — CreateVolume
in Chapter 7 is about to skip it, on purpose, and pay for that choice
in Chapter 9.
Writing NodeUnpublishVolume, test-first
Same rhythm, new method. Start with validation, the same shape as
NodePublishVolume's first test. Add this below
TestNodePublishVolume_AlreadyMounted:
func TestNodeUnpublishVolume_Validation(t *testing.T) {
cases := []validationCase[*csi.NodeUnpublishVolumeRequest]{
{name: "missing volume ID", req: &csi.NodeUnpublishVolumeRequest{TargetPath: "/target"}},
{name: "missing target path", req: &csi.NodeUnpublishVolumeRequest{VolumeId: "vol-1"}},
}
runValidation(t, cases,
func(t *testing.T) *Driver { return newTestDriver(t, dataDir, &fakeMounter{}) },
func(t *testing.T, d *Driver, req *csi.NodeUnpublishVolumeRequest) error {
_, err := d.NodeUnpublishVolume(t.Context(), req)
return err
},
)
}
go test ./internal/driver/...
Red — NodeUnpublishVolume is still only the promoted
UnimplementedNodeServer stub, the same Unimplemented-code shape
NodePublishVolume's first test hit. And the same testing_test.go
attribution as before, for the same reason:
--- FAIL: TestNodeUnpublishVolume_Validation (0.00s)
--- FAIL: TestNodeUnpublishVolume_Validation/missing_volume_ID (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId: TargetPath:/target})
--- FAIL: TestNodeUnpublishVolume_Validation/missing_target_path (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{VolumeId:vol-1 TargetPath:})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
Write just enough to fix it — the same two-check, stub-return shape
NodePublishVolume started with:
func (d *Driver) NodeUnpublishVolume(
ctx context.Context,
req *csi.NodeUnpublishVolumeRequest,
) (*csi.NodeUnpublishVolumeResponse, error) {
if req.GetVolumeId() == "" {
return nil, status.Error(codes.InvalidArgument, "volume_id is required")
}
if req.GetTargetPath() == "" {
return nil, status.Error(codes.InvalidArgument, "target_path is required")
}
return &csi.NodeUnpublishVolumeResponse{}, nil
}
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.005s
Green. Next: actually unmounting and cleaning up. Add this below
TestNodeUnpublishVolume_Validation:
func TestNodeUnpublishVolume_UnmountsAndRemoves(t *testing.T) {
targetPath := filepath.Join(t.TempDir(), "target")
if err := os.Mkdir(targetPath, 0750); err != nil {
t.Fatalf("setting up fake target path: %v", err)
}
mounter := &fakeMounter{mounted: true}
d := newTestDriver(t, dataDir, mounter)
req := &csi.NodeUnpublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: targetPath,
}
if _, err := d.NodeUnpublishVolume(t.Context(), req); err != nil {
t.Fatalf("NodeUnpublishVolume() returned an error: %v", err)
}
if len(mounter.unmountCalls) != 1 || mounter.unmountCalls[0] != targetPath {
t.Errorf("unmountCalls = %v, want [%q]", mounter.unmountCalls, targetPath)
}
if _, err := os.Stat(targetPath); !os.IsNotExist(err) {
t.Errorf("target path still exists after NodeUnpublishVolume")
}
}
go test ./internal/driver/...
Red — the stub from the last step returns success without ever calling
Unmount or removing anything (again, the exact temp path in your
output will be different from this one):
--- FAIL: TestNodeUnpublishVolume_UnmountsAndRemoves (0.00s)
node_test.go:235: unmountCalls = [], want ["/tmp/TestNodeUnpublishVolume_UnmountsAndRemoves1008210749/001/target"]
node_test.go:239: target path still exists after NodeUnpublishVolume
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.004s
FAIL
Fix it. The CSI spec's own text for this method says the driver "MUST
delete the file or directory it created" at target_path — the
os.RemoveAll below isn't tidiness, it's a spec requirement:
if err := d.mount.Unmount(req.GetTargetPath()); err != nil {
return nil, status.Errorf(codes.Internal, "unmounting %q: %v", req.GetTargetPath(), err)
}
if err := os.RemoveAll(req.GetTargetPath()); err != nil {
return nil, status.Errorf(codes.Internal, "removing target path: %v", err)
}
// Clean up the publish metadata NodePublishVolume recorded, so a
// volume republished later starts with no stale record of what an
// earlier, now-unpublished call did.
if err := os.Remove(nodePublishMetaPath(d.dataDir, req.GetVolumeId())); err != nil && !os.IsNotExist(err) {
return nil, status.Errorf(codes.Internal, "deleting volume %q publish metadata: %v", req.GetVolumeId(), err)
}
return &csi.NodeUnpublishVolumeResponse{}, nil
This replaces the old return &csi.NodeUnpublishVolumeResponse{}, nil
stub, right after the two validation checks — and closes the loop on
the metadata file NodePublishVolume started writing earlier in this
chapter.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.007s
Green. Last case: idempotency, the same property NodePublishVolume
needed. Add this below TestNodeUnpublishVolume_UnmountsAndRemoves:
func TestNodeUnpublishVolume_NotMounted(t *testing.T) {
mounter := &fakeMounter{isMountPointErr: os.ErrNotExist}
d := newTestDriver(t, dataDir, mounter)
req := &csi.NodeUnpublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: filepath.Join(t.TempDir(), "already-gone"),
}
if _, err := d.NodeUnpublishVolume(t.Context(), req); err != nil {
t.Fatalf("NodeUnpublishVolume() returned an error: %v", err)
}
if len(mounter.unmountCalls) != 0 {
t.Errorf("got %d Unmount calls, want 0 — nothing was mounted", len(mounter.unmountCalls))
}
}
go test ./internal/driver/...
Red — NodeUnpublishVolume still calls Unmount unconditionally, so it
does even when the fake reports the target was never there in the first
place:
--- FAIL: TestNodeUnpublishVolume_NotMounted (0.00s)
node_test.go:257: got 1 Unmount calls, want 0 — nothing was mounted
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.003s
FAIL
Fix it the same way NodePublishVolume learned to ask first:
mounted, err := d.mount.IsMountPoint(req.GetTargetPath())
if err != nil {
if os.IsNotExist(err) {
// Nothing left to clean up — this is what a retried call
// after a previous, fully-successful one looks like.
return &csi.NodeUnpublishVolumeResponse{}, nil
}
return nil, status.Errorf(codes.Internal, "checking target path: %v", err)
}
if mounted {
if err := d.mount.Unmount(req.GetTargetPath()); err != nil {
return nil, status.Errorf(codes.Internal, "unmounting %q: %v", req.GetTargetPath(), err)
}
}
Replace the unconditional Unmount call with this — the os.RemoveAll
call that follows stays exactly as it was.
go test ./internal/driver/... -v
=== RUN TestGetPluginInfo
--- PASS: TestGetPluginInfo (0.00s)
=== RUN TestGetPluginCapabilities
--- PASS: TestGetPluginCapabilities (0.00s)
=== RUN TestProbe
=== RUN TestProbe/backend_is_healthy
=== RUN TestProbe/backend_is_unhealthy
--- PASS: TestProbe (0.00s)
--- PASS: TestProbe/backend_is_healthy (0.00s)
--- PASS: TestProbe/backend_is_unhealthy (0.00s)
=== RUN TestNodeGetInfo
--- PASS: TestNodeGetInfo (0.00s)
=== RUN TestNodeGetCapabilities
--- PASS: TestNodeGetCapabilities (0.00s)
=== RUN TestNodePublishVolume_Validation
=== RUN TestNodePublishVolume_Validation/missing_volume_ID
=== RUN TestNodePublishVolume_Validation/missing_target_path
=== RUN TestNodePublishVolume_Validation/missing_volume_capability
--- PASS: TestNodePublishVolume_Validation (0.00s)
--- PASS: TestNodePublishVolume_Validation/missing_volume_ID (0.00s)
--- PASS: TestNodePublishVolume_Validation/missing_target_path (0.00s)
--- PASS: TestNodePublishVolume_Validation/missing_volume_capability (0.00s)
=== RUN TestNodePublishVolume_VolumeNotFound
--- PASS: TestNodePublishVolume_VolumeNotFound (0.00s)
=== RUN TestNodePublishVolume_MountsTheVolume
--- PASS: TestNodePublishVolume_MountsTheVolume (0.00s)
=== RUN TestNodePublishVolume_Readonly
--- PASS: TestNodePublishVolume_Readonly (0.00s)
=== RUN TestNodePublishVolume_AlreadyMounted
--- PASS: TestNodePublishVolume_AlreadyMounted (0.00s)
=== RUN TestNodeUnpublishVolume_Validation
=== RUN TestNodeUnpublishVolume_Validation/missing_volume_ID
=== RUN TestNodeUnpublishVolume_Validation/missing_target_path
--- PASS: TestNodeUnpublishVolume_Validation (0.00s)
--- PASS: TestNodeUnpublishVolume_Validation/missing_volume_ID (0.00s)
--- PASS: TestNodeUnpublishVolume_Validation/missing_target_path (0.00s)
=== RUN TestNodeUnpublishVolume_UnmountsAndRemoves
--- PASS: TestNodeUnpublishVolume_UnmountsAndRemoves (0.00s)
=== RUN TestNodeUnpublishVolume_NotMounted
--- PASS: TestNodeUnpublishVolume_NotMounted (0.00s)
PASS
ok github.com/yourname/localdir-csi/internal/driver 0.007s
Green, and every test written across both methods still passes — eight small red/green cycles, none of them touching a real filesystem mount, adding up to the two methods that let a pod actually use a volume.
Letting the container actually mount things
A working Mounter is only half the story — the container it runs in
needs the mount command itself to actually exist, needs to be allowed
to call it, and needs to be able to reach the exact host path kubelet
expects a volume to land at. All three are easy to get wrong silently.
k8s.io/mount-utils's Mount doesn't perform a bind mount through a
Linux system call directly — it shells out to a real mount executable,
the same one you'd run by typing mount at a terminal, resolved from
whatever $PATH the process sees. The distroless image Chapter 4 built
the final container stage from was chosen because it has nothing beyond
the compiled binary and the C library it links against — no shell, no
coreutils, no mount. That never mattered before this chapter, since
nothing earlier ever shelled out to anything, but NodePublishVolume
fails the moment it's asked to mount something for real:
mounting volume "test-vol-1": mount failed: exec: "mount": executable file not found in $PATH
Fix it by building the final image from something that actually ships a
mount binary. Alpine's package manager makes this a small, deliberate
addition rather than an all-or-nothing tradeoff — util-linux is the
package that provides a real mount:
# Stage 2: the image that actually ships. Alpine is still small — a few
# megabytes, not a full distro — but unlike the fully binary-less
# distroless image, it can host the one external command NodePublishVolume
# genuinely needs: a real mount.
FROM alpine:3.24
RUN apk add --no-cache util-linux
COPY --from=build /out/localdir-csi /localdir-csi
ENTRYPOINT ["/localdir-csi"]
This replaces the Dockerfile's second stage from Chapter 4 — everything
from FROM gcr.io/distroless/static-debian12 onward — while the first
stage, the one that actually compiles the binary, is unchanged. That
first stage's CGO_ENABLED=0 flag is exactly why the swap is safe: it
produces a binary with no dynamic C library dependency at all, so it runs
identically whichever C library the final image happens to carry —
glibc, in distroless, or musl, in Alpine. csi-driver-host-path, the
reference CSI implementation this chapter has already leaned on for
real-world detail, makes the identical choice for the identical reason:
its own node plugin builds on Alpine with util-linux installed, because
a driver that calls mount needs mount to exist somewhere inside its
own container.
Linux treats mounting — even a bind mount between two directories the
calling process already owns — as a privileged operation, gated behind a
capability ordinary containers don't have by default. Without it,
d.mount.Mount(...) returns a permission error the instant it runs for
real, regardless of how correct the surrounding Go code is. Grant it by
adding a securityContext to the localdir-csi container:
- name: localdir-csi
image: localdir-csi:dev
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
Separately, target_path lives under /var/lib/kubelet/pods/ on the
node — kubelet's own directory, not one your driver controls, and not
one it currently has any access to at all. It needs a hostPath mount
for that directory too, the same pattern Chapter 4 used for the socket
directory. But a plain hostPath mount isn't quite enough here, and it's
worth understanding why.
Every container gets its own private view of what's currently mounted
where — a mount namespace. By default, a mount --bind your driver
performs inside its own container stays inside that container's mount
namespace; kubelet, watching the real host filesystem from outside any
container, would never see it happen. mountPropagation: Bidirectional
on a volume mount widens that in both directions: mounts made inside the
container become visible on the host (and to other containers sharing
the mount), and mounts made on the host become visible inside the
container. Without it, NodePublishVolume would report success while
kubelet's own view of the filesystem never changed — a working RPC call
producing an invisible result.
Add both the volume mount and the corresponding volume:
volumeMounts:
- name: socket-dir
mountPath: /csi
- name: data-dir
mountPath: /data
- name: mountpoint-dir
mountPath: /var/lib/kubelet/pods
mountPropagation: Bidirectional
- name: mountpoint-dir
hostPath:
path: /var/lib/kubelet/pods
type: Directory
type: Directory, not DirectoryOrCreate — like registration-dir in
Chapter 4, this is a path kubelet itself manages and already creates on
every node it runs on; if it's missing, something more fundamental than
this manifest is wrong.
The full, current deploy/node.yaml:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: localdir-csi-node
labels:
app: localdir-csi
spec:
selector:
matchLabels:
app: localdir-csi
template:
metadata:
labels:
app: localdir-csi
spec:
containers:
- name: localdir-csi
image: localdir-csi:dev
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: socket-dir
mountPath: /csi
- name: data-dir
mountPath: /data
- name: mountpoint-dir
mountPath: /var/lib/kubelet/pods
mountPropagation: Bidirectional
- name: node-driver-registrar
image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.17.0
args:
- --v=2
- --csi-address=/csi/csi.sock
- --kubelet-registration-path=/var/lib/kubelet/plugins/localdir.csi.example.com/csi.sock
volumeMounts:
- name: socket-dir
mountPath: /csi
- name: registration-dir
mountPath: /registration
volumes:
- name: socket-dir
hostPath:
path: /var/lib/kubelet/plugins/localdir.csi.example.com/
type: DirectoryOrCreate
- name: registration-dir
hostPath:
path: /var/lib/kubelet/plugins_registry/
type: Directory
- name: data-dir
hostPath:
path: /var/lib/localdir-csi/data
type: DirectoryOrCreate
- name: mountpoint-dir
hostPath:
path: /var/lib/kubelet/pods
type: Directory
Wiring up a real Mounter in main.go
internal/driver's tests never need a real mounter, but main.go does.
Pull in the library:
go get k8s.io/mount-utils
k8s.io/mount-utils declares in its own go.mod that it needs Go 1.26 or
newer, and Go's module system won't let a module depend on something that
requires a newer Go version than it declares itself — so this command
also raises go.mod's own go line to 1.26.0. On your own machine
that change is invisible: Go's toolchain manager defaults to quietly
downloading whatever newer version a go.mod asks for the moment it's
actually needed, so go build and go test keep working without you
noticing anything happened. Inside Docker it isn't invisible, because the
official golang images deliberately turn that automatic downloading off
(GOTOOLCHAIN=local), so a build stays reproducible without needing
network access partway through. That means the build stage of the
Dockerfile from Chapter 4 needs its baked-in Go version raised to match.
Update its first line:
FROM golang:1.26 AS build
Everything else in the Dockerfile is unchanged.
Update cmd/localdir-csi/main.go:
package main
import (
"flag"
"log"
"net"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
mount "k8s.io/mount-utils"
"github.com/container-storage-interface/spec/lib/go/csi"
"github.com/yourname/localdir-csi/internal/driver"
)
const (
driverName = "localdir.csi.example.com"
driverVersion = "0.1.0"
)
func main() {
endpoint := flag.String("endpoint", "unix:///csi/csi.sock",
"gRPC endpoint the driver listens on, as a unix:// URI")
dataDir := flag.String("data-dir", "/data",
"directory volumes are stored under, and the health check reads and writes to confirm local storage is usable")
flag.Parse()
socketPath, err := driver.SocketPathFromEndpoint(*endpoint)
if err != nil {
log.Fatalf("invalid -endpoint: %v", err)
}
nodeID := os.Getenv("NODE_NAME")
if nodeID == "" {
// NODE_NAME is set by the Downward API in deploy/node.yaml, so
// this branch never runs in the cluster. It exists purely so
// `make run`, on your own laptop, still has something sensible
// to report — your laptop genuinely doesn't have a Kubernetes
// node name, so falling back to its real hostname is the closest
// honest answer available locally.
hostname, err := os.Hostname()
if err != nil {
log.Fatalf("NODE_NAME not set, and failed to read hostname as a fallback: %v", err)
}
nodeID = hostname
}
// If a socket file from a previous run is still sitting there, remove
// it. Without this, trying to listen on the same path a second time
// fails with "address already in use" — a file on disk, not an
// actual network port, but Go's net package still treats it that way.
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
log.Fatalf("failed to remove existing socket: %v", err)
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("failed to listen on %s: %v", socketPath, err)
}
d := driver.NewDriver(
driverName,
driverVersion,
&driver.LocalDirHealthChecker{Root: *dataDir},
nodeID,
*dataDir,
mount.New(""),
)
server := grpc.NewServer()
csi.RegisterIdentityServer(server, d)
csi.RegisterNodeServer(server, d)
reflection.Register(server)
log.Printf("localdir-csi listening on %s (node %s)", socketPath, nodeID)
if err := server.Serve(listener); err != nil {
log.Fatalf("server stopped: %v", err)
}
}
mount.New("") is k8s.io/mount-utils's own constructor for a real,
Linux-backed mounter — the empty string is the path to the mount
binary it shells out to for some operations, and an empty string tells
it to use the default, /bin/mount. The package name is mount, which
is why the import gets an explicit mount prefix here even though it's
already the default — k8s.io/mount-utils is a fairly generic-sounding
import path to leave unlabeled in a file that also has a field named
mount on Driver itself.
*dataDir now appears twice in the NewDriver call — once building the
health checker, once as Driver's own data directory. That's not
duplication to clean up: LocalDirHealthChecker uses it to confirm the
directory is writable at all, while Driver uses it to compute
per-volume paths. Two different components, two different reasons to
know the same root path.
Deploying
make deploy
kubectl get pods -l app=localdir-csi -w
Same 2/2 Running result as Chapter 5 — privileged: true and the new
mountpoint-dir mount don't change anything kubelet checks during
registration, so nothing about the pod's health should look different
yet. What's different is what the container is now allowed to do,
which the next section proves.
Proving it, inside the node
Nothing in the cluster calls NodePublishVolume automatically yet —
that starts once a real pod requests a volume through a
PersistentVolumeClaim, which is Chapter 8's territory. For now, calling
it directly is the only way to watch it work, and Chapter 2 already told
you where: inside the kind node itself, a real Linux container, the
same place these bind mounts actually happen.
grpcurl isn't installed inside the node, so the first step is getting a
copy of it there. Rather than guessing at which of several release
archives matches the node's own CPU architecture, pull it straight out of
grpcurl's own official image — a multi-architecture build that already
matches whatever platform your kind node happens to be running on:
docker create --name grpcurl-tmp fullstorydev/grpcurl:v1.9.3
docker cp grpcurl-tmp:/bin/grpcurl ./grpcurl-linux
docker rm grpcurl-tmp
docker cp ./grpcurl-linux csi-dev-control-plane:/usr/local/bin/grpcurl
docker exec csi-dev-control-plane chmod +x /usr/local/bin/grpcurl
csi-dev-control-plane is the actual Docker container backing your
cluster's one node — kind names it <cluster-name>-control-plane,
and Chapter 2 named the cluster csi-dev.
Chapter 7 will teach CreateVolume to make a volume's data directory
automatically. Until then, stand in for it by hand — this is exactly the
directory structure CreateVolume will produce later, just created
directly instead of through a gRPC call:
docker exec csi-dev-control-plane mkdir -p /var/lib/localdir-csi/data/test-vol-1
docker exec csi-dev-control-plane sh -c 'echo "hello from localdir-csi" > /var/lib/localdir-csi/data/test-vol-1/hello.txt'
Now call NodePublishVolume for real, against the driver's actual
running socket, at a target_path shaped like the one kubelet would
really choose for a pod. Every grpcurl call so far in this book has
called a method that takes no arguments; this one doesn't, so it needs
-d, followed by the request as JSON, using the same field names as the
.proto file:
docker exec csi-dev-control-plane grpcurl -plaintext -d '{
"volume_id": "test-vol-1",
"target_path": "/var/lib/kubelet/pods/test-pod/volumes/kubernetes.io~csi/test-vol-1/mount",
"volume_capability": {"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}
}' unix:/var/lib/kubelet/plugins/localdir.csi.example.com/csi.sock csi.v1.Node/NodePublishVolume
{}
An empty response — exactly what the spec says
NodePublishVolumeResponse should be, and exactly the same "success
looks like nothing" shape NodeGetCapabilities's empty capability list
had in Chapter 5. The proof isn't in that response; it's in whether the
bind mount actually happened:
docker exec csi-dev-control-plane cat /var/lib/kubelet/pods/test-pod/volumes/kubernetes.io~csi/test-vol-1/mount/hello.txt
hello from localdir-csi
The file you wrote at /var/lib/localdir-csi/data/test-vol-1/hello.txt
is readable through an entirely different path — the one your driver was
told to publish at, and the one a real container's own filesystem would
be pointed at inside a pod. That's the whole chapter, proven with one
cat.
Reverse it:
docker exec csi-dev-control-plane grpcurl -plaintext -d '{
"volume_id": "test-vol-1",
"target_path": "/var/lib/kubelet/pods/test-pod/volumes/kubernetes.io~csi/test-vol-1/mount"
}' unix:/var/lib/kubelet/plugins/localdir.csi.example.com/csi.sock csi.v1.Node/NodeUnpublishVolume
{}
docker exec csi-dev-control-plane ls /var/lib/kubelet/pods/test-pod/volumes/kubernetes.io~csi/test-vol-1/
ls: cannot access '/var/lib/kubelet/pods/test-pod/volumes/kubernetes.io~csi/test-vol-1/': No such file or directory
Gone — NodeUnpublishVolume unmounted it and removed the directory it
had created, leaving the original data at /var/lib/localdir-csi/data/test-vol-1/hello.txt
completely untouched. Confirm that part too, if you want the full
picture:
docker exec csi-dev-control-plane cat /var/lib/localdir-csi/data/test-vol-1/hello.txt
hello from localdir-csi
What you should have now
internal/driver/driver.go:Drivercarrying adataDirand a narrow, self-declaredMounterinterface — satisfied automatically byk8s.io/mount-utils's real mounter, no adapter requiredinternal/driver/node.go:NodePublishVolumeandNodeUnpublishVolume, both idempotent, both tested against a fakeMounterbefore either touched real code- A sidecar-metadata-file pattern (
nodePublishMeta,writeNodePublishMeta,readNodePublishMeta) — this book's first, recording whatNodePublishVolumeactually did so a later call can tell a genuine retry from a real conflict, instead of trustingIsMountPoint's baretrue/falsealone cmd/localdir-csi/main.gobuilding a realmount.New("")and passing it, along with the data directory, intoDriverdeploy/node.yaml: thelocaldir-csicontainer runningprivileged, with amountpoint-dirvolume mountedBidirectionalso mounts made inside the container become visible to kubelet- Direct, hands-on proof — a file written on one path, read back through
a completely different one, inside a real
kindnode — that a bind mount your own code triggered actually happened
Four of csi.NodeServer's ten methods are done. What's still missing is
everything upstream of them: nothing yet creates a volume's data
directory, or decides how big it should be, or lets it be deleted. That's
the Controller service, and it's next, in Chapter 7.