Chapter 7: The Controller Service
Every RPC so far has assumed a volume's directory already exists.
NodePublishVolume looks one up at filepath.Join(d.dataDir, req.GetVolumeId())
and fails with NotFound if it isn't there — nothing before now has ever
created it. That's the Controller service's job: CreateVolume and
DeleteVolume, the two RPCs that make a volume exist and stop existing,
answered by a completely different piece of Kubernetes than anything
you've deployed yet.
Who calls Controller, and why it's a Deployment, not a DaemonSet
The Node service runs once per node because mounting is inherently a
per-node fact — Chapter 1 covered that, and it's why deploy/node.yaml is
a DaemonSet. Creating and deleting a volume isn't a per-node fact at
all. Nothing about deciding "this volume should exist, with this much
capacity" needs to happen on any particular machine, and running it on
every node would just mean every node's copy racing to create the same
directory. This driver's Controller service runs as an ordinary
Deployment, a single replica, same as internal/driver/controller.go
would run wherever that Deployment happened to land.
Nothing calls CreateVolume directly, either — not kubectl, not a
person. It's called by a sidecar, external-provisioner, that
Kubernetes's own storage machinery ships as a separate container image,
not as anything you write. external-provisioner watches
PersistentVolumeClaim objects cluster-wide, and when it sees one that
asks for this driver by name, it translates that claim into a
CreateVolume gRPC call against whatever's listening on the socket it
shares with your Controller container — the emptyDir-socket pattern
Chapter 1 described in general, and Chapter 4 named directly as exactly
how this chapter would work, when it explained why node-driver-registrar
needed a hostPath instead.
A concrete way to picture the whole loop: a PersistentVolumeClaim is a
request card — "I need 1Gi of storage from localdir-csi" — dropped
into a box that every storage driver's own clerk is watching.
external-provisioner is localdir-csi's clerk, and only that driver's
clerk; a claim asking for a different driver by name never reaches it at
all. When it spots a card addressed to your driver, it doesn't create
anything itself — it can't, it has no idea what "creating a volume"
even means for localdir-csi specifically. It just picks up the phone
and relays the request over gRPC: "make me a volume matching this
card." Your CreateVolume method does the actual work and hands back a
Volume, and external-provisioner turns that into a PersistentVolume
object — a receipt, visible to the rest of the cluster, tying the
original request card to a real, now-existing volume.
PersistentVolumeClaim objects, and external-provisioner actually
watching one, are Chapter 8's territory; this chapter proves
CreateVolume and DeleteVolume work the same way Chapter 6 proved
NodePublishVolume did — with grpcurl, calling the RPC directly, no
claim or clerk involved yet.
Driver gains a third embedded interface
ControllerServer is the third of the three interfaces Driver
satisfies, alongside IdentityServer and NodeServer — same shape,
same reason: a real csi.ControllerServer requires an unexported
mustEmbedUnimplementedControllerServer() method, satisfiable only by
embedding csi.UnimplementedControllerServer, for the same forward-
compatibility reason Chapter 3 walked through for Identity. Update
internal/driver/driver.go:
type Driver struct {
csi.UnimplementedIdentityServer
csi.UnimplementedControllerServer
csi.UnimplementedNodeServer
name string
version string
health HealthChecker
nodeID string
dataDir string
mount Mounter
}
And add a third compile-time check alongside the existing two:
var _ csi.IdentityServer = (*Driver)(nil)
var _ csi.ControllerServer = (*Driver)(nil)
var _ csi.NodeServer = (*Driver)(nil)
Run the tests:
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Still green — and worth noticing what's different from every previous
chapter's version of this same step. Chapters 5 and 6 both grew
NewDriver's parameter list at the same time they added an interface,
which broke every existing call site and meant a whole extra red/green
cycle just to catch up. This time NewDriver doesn't change at all:
Controller needs exactly two things Driver already has —
dataDir, sitting there since Chapter 6, and nothing else. CreateVolume
and DeleteVolume don't touch mount, don't touch health, don't need
a fourth constructor parameter. Giving dataDir a home on Driver
itself back in Chapter 6, rather than tucking it away inside Node's own
state, is what makes it free to reach for here.
ControllerGetCapabilities, and why it can't stay empty
NodeGetCapabilities has returned an empty list since Chapter 5, and
nothing has ever complained — kubelet doesn't gate anything on it.
ControllerGetCapabilities is different, and skipping it isn't an
option: external-provisioner calls it before it will call CreateVolume
at all, and if the response doesn't list CREATE_DELETE_VOLUME among the
driver's capabilities, external-provisioner treats that as "this driver
doesn't do provisioning" and never sends a single CreateVolume request,
no matter how many PersistentVolumeClaims show up. An empty list here
wouldn't be a smaller, more honest answer the way it was for Node — it
would silently disable the entire chapter.
Test first:
func TestControllerGetCapabilities(t *testing.T) {
d := newTestDriver(t, dataDir, nil)
resp, err := d.ControllerGetCapabilities(t.Context(), &csi.ControllerGetCapabilitiesRequest{})
if err != nil {
t.Fatalf("ControllerGetCapabilities returned an error: %v", err)
}
if len(resp.Capabilities) != 1 {
t.Fatalf("got %d capabilities, want 1", len(resp.Capabilities))
}
rpc := resp.Capabilities[0].GetRpc()
if rpc == nil {
t.Fatal("expected an RPC capability, got something else")
}
if rpc.Type != csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME {
t.Errorf("capability type = %v, want CREATE_DELETE_VOLUME", rpc.Type)
}
}
Add this to a new file, internal/driver/controller_test.go, with
"testing" and the csi package as its only imports for now. newTestDriver
and dataDir need nothing new here — they're the same helper and the same
package-level test constant testing_test.go and identity_test.go defined
back in Chapter 6, and every file in the driver package shares them for
free.
ControllerServiceCapability mirrors the shape PluginCapability used
back in Chapter 3 for GetPluginCapabilities — a wrapper type with a
oneof-style Type field, here holding an Rpc variant instead of a
Service one, and a nested enum naming which specific capability this
entry describes. GetRpc() is that variant's accessor, the same pattern
GetService() was — note the capitalization: the underlying spec field
is named rpc, and the generated Go code capitalizes field names
letter-by-letter rather than treating rpc as an acronym, so it comes
out Rpc, not RPC. The type nested one level down, RPC, keeps its
own capitalization exactly as declared, since it's a message name, not a
field name — this asymmetry (GetRpc() returning a *ControllerServiceCapability_RPC)
is easy to mistype from memory and worth double-checking against the
real generated code rather than guessing.
Run it:
go test ./internal/driver/...
Red, the familiar shape for a method still resolving to its embedded stub:
--- FAIL: TestControllerGetCapabilities (0.00s)
controller_test.go:14: ControllerGetCapabilities returned an error: rpc error: code = Unimplemented desc = method ControllerGetCapabilities not implemented
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.002s
FAIL
Implement it. Create internal/driver/controller.go:
package driver
import (
"context"
"github.com/container-storage-interface/spec/lib/go/csi"
)
func (d *Driver) ControllerGetCapabilities(
ctx context.Context,
req *csi.ControllerGetCapabilitiesRequest,
) (*csi.ControllerGetCapabilitiesResponse, error) {
return &csi.ControllerGetCapabilitiesResponse{
Capabilities: []*csi.ControllerServiceCapability{
{
Type: &csi.ControllerServiceCapability_Rpc{
Rpc: &csi.ControllerServiceCapability_RPC{
Type: csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME,
},
},
},
},
}, nil
}
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.002s
Green. The same Open/Closed shape Chapter 3 pointed out for
GetPluginCapabilities applies again here: this returns a one-entry
slice literal, not a hand-built response with a single hardcoded field,
specifically so that a later chapter adding LIST_VOLUMES or
GET_CAPACITY support means appending an entry, not restructuring
this method or touching TestControllerGetCapabilities, which only
ever asserts Capabilities[0].
CreateVolume, test-first: validation
CreateVolumeRequest carries more fields than this driver uses —
secrets, volume_content_source, accessibility_requirements, and a
newer mutable_parameters field the spec added after parameters
already existed. Only two are things CreateVolume actually needs to
check before doing anything: name, because every volume this driver
creates is identified by it, and volume_capabilities, because the spec
requires at least one entry describing how the volume needs to be
usable, the same VolumeCapability shape NodePublishVolume already
validates. One test at a time, same rhythm as every RPC so far:
func TestCreateVolume_Validation(t *testing.T) {
cases := []validationCase[*csi.CreateVolumeRequest]{
{
name: "missing name",
req: &csi.CreateVolumeRequest{
VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
},
},
{
name: "missing volume capabilities",
req: &csi.CreateVolumeRequest{
Name: "vol-1",
},
},
}
runValidation(t, cases, newTestDriverInTempDir,
func(t *testing.T, d *Driver, req *csi.CreateVolumeRequest) error {
_, err := d.CreateVolume(t.Context(), req)
return err
},
)
}
No new imports needed. validationCase[T] and runValidation are the
same generic helpers node_test.go reached for in Chapter 6 to collapse
TestNodePublishVolume_Validation and TestNodeUnpublishVolume_Validation
into a table plus one call — CreateVolume's own two-row validation table
is exactly the shape they were built for, no new machinery required.
mountCapability() is the same test-only helper Chapter 6 moved into
testing_test.go — it lives in the same driver package, so every test
file shares it without any new code.
Run it:
go test ./internal/driver/...
--- FAIL: TestCreateVolume_Validation (0.00s)
--- FAIL: TestCreateVolume_Validation/missing_name (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{Name: CapacityRange:<nil> VolumeCapabilities:[0xc0000d00a8] Parameters:map[] Secrets:map[] VolumeContentSource:<nil> AccessibilityRequirements:<nil>})
--- FAIL: TestCreateVolume_Validation/missing_volume_capabilities (0.00s)
testing_test.go:85: code = Unimplemented, want InvalidArgument (req=&{Name:vol-1 CapacityRange:<nil> VolumeCapabilities:[] Parameters:map[] Secrets:map[] VolumeContentSource:<nil> AccessibilityRequirements:<nil>})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.005s
FAIL
Both failures point at testing_test.go:85, not at this test's own call
to runValidation — the same t.Helper() mechanic Chapter 6 worked
through for TestNodePublishVolume_Validation. requireStatusCode calls
t.Helper(), so a failure inside it is attributed to whichever frame
called it that isn't itself marked as a helper. That frame is the
anonymous closure t.Run invokes inside runValidation — runValidation
itself calls t.Helper() too, but the closure it defines does not — so
the blame lands on testing_test.go:85, the requireStatusCode(...) line
inside that closure, every time this pattern is used, in this chapter and
every one after it.
Write just enough of CreateVolume to fix it — the two checks, and, for
now, a stub success return. Create internal/driver/controller.go's new
method (below ControllerGetCapabilities, with codes and status
added to the file's imports):
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")
}
return &csi.CreateVolumeResponse{Volume: &csi.Volume{}}, nil
}
That stub return is deliberately not nil, nil. CreateVolumeResponse's
volume field is REQUIRED by the spec — a real CO receiving a nil
Volume on an otherwise-successful response would be looking at a
broken driver — so even a placeholder has to be a real, empty *Volume,
never a missing one. It costs nothing here and avoids a self-inflicted
nil-pointer panic the moment the next test reads a field off it.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.004s
Green.
CreateVolume, test-first: actually creating something
Next case: a valid request should create a directory and hand back a
Volume describing it.
func TestCreateVolume_CreatesTheVolume(t *testing.T) {
dataDir := t.TempDir()
d := newTestDriver(t, dataDir, nil)
req := &csi.CreateVolumeRequest{
Name: "vol-1",
CapacityRange: &csi.CapacityRange{RequiredBytes: 1 << 20},
VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
}
resp, err := d.CreateVolume(t.Context(), req)
if err != nil {
t.Fatalf("CreateVolume() returned an error: %v", err)
}
if resp.Volume.VolumeId != "vol-1" {
t.Errorf("VolumeId = %q, want %q", resp.Volume.VolumeId, "vol-1")
}
if resp.Volume.CapacityBytes != 1<<20 {
t.Errorf("CapacityBytes = %d, want %d", resp.Volume.CapacityBytes, 1<<20)
}
if info, err := os.Stat(filepath.Join(dataDir, "vol-1")); err != nil || !info.IsDir() {
t.Fatalf("volume directory was not created: %v", err)
}
}
Add "os" and "path/filepath" to controller_test.go's imports.
go test ./internal/driver/...
Red — the previous step's stub returns an empty Volume and creates
nothing:
--- FAIL: TestCreateVolume_CreatesTheVolume (0.00s)
controller_test.go:71: VolumeId = "", want "vol-1"
controller_test.go:74: CapacityBytes = 0, want 1048576
controller_test.go:78: volume directory was not created: stat /tmp/TestCreateVolume_CreatesTheVolume.../vol-1: no such file or directory
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.006s
FAIL
Fix it. This is the same filepath.Join(d.dataDir, ...) convention
NodePublishVolume already assumes in Chapter 6 — a volume's data lives
in a directory named after its own ID, directly under the driver's data
root. CreateVolume is what actually creates that directory, using a
volume's name as its ID:
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)
}
return &csi.CreateVolumeResponse{
Volume: &csi.Volume{
VolumeId: req.GetName(),
CapacityBytes: req.GetCapacityRange().GetRequiredBytes(),
},
}, nil
That replaces the old stub return — insert it right after the two
validation checks, and add "os" and "path/filepath" to
controller.go's imports too.
req.GetCapacityRange().GetRequiredBytes() chains two nil-safe accessors
on purpose: capacity_range is an OPTIONAL field of CreateVolumeRequest,
so a real request might not set it at all, and calling GetRequiredBytes()
on a nil *CapacityRange needs to return 0 rather than panic — the
same nil-receiver pattern every generated getter in the csi package
already follows, req.GetVolumeId() included.
Worth being honest about what this line does and doesn't do:
localdir-csi never actually enforces CapacityRange — nothing checks
that the underlying disk has RequiredBytes free, and nothing prevents
someone writing more data into the volume than they asked for. It just
remembers whatever number it was given and reports it back, which is
enough to satisfy a PersistentVolumeClaim's status.capacity in
Chapter 8, but a real backend — one that actually provisions block
devices or files of a specific size — is where that number would turn
into an actual constraint. RequiredBytes and LimitBytes sitting at
0 (their zero value, if a CO never sets CapacityRange at all) is
commonly treated as "no particular size requested" by drivers in
practice, but that's a convention this codebase is choosing to follow,
not something the spec text itself states as a MUST.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Green.
CreateVolume, test-first: idempotency
The spec is specific about what CreateVolume has to do when it's
asked to create a volume that already exists: MUST reply 0 OK if the
existing volume matches what was asked for, and there's a good reason —
PersistentVolumeClaim provisioning isn't guaranteed exactly-once.
external-provisioner can retry a CreateVolume call it never got a
response for, and a driver that treats the second call as an error
turns a harmless network hiccup into a permanently stuck claim.
func TestCreateVolume_Idempotent(t *testing.T) {
dataDir := t.TempDir()
d := newTestDriver(t, dataDir, nil)
req := &csi.CreateVolumeRequest{
Name: "vol-1",
CapacityRange: &csi.CapacityRange{RequiredBytes: 1 << 20},
VolumeCapabilities: []*csi.VolumeCapability{mountCapability()},
}
if _, err := d.CreateVolume(t.Context(), req); err != nil {
t.Fatalf("first CreateVolume() returned an error: %v", err)
}
resp, err := d.CreateVolume(t.Context(), req)
if err != nil {
t.Fatalf("second CreateVolume() for the same name returned an error: %v", err)
}
if resp.Volume.VolumeId != "vol-1" {
t.Errorf("VolumeId = %q, want %q", resp.Volume.VolumeId, "vol-1")
}
}
Run it:
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Already green, no implementation change needed — the same shape of
free correctness Chapter 6 found in NodeUnpublishVolume, just on the
other side of the volume's lifecycle. os.MkdirAll doesn't error when
the directory it's asked to create already exists; it only errors when
something actually goes wrong. Calling CreateVolume twice for the
same name calls MkdirAll twice on the same path, and the second call
is a no-op success, not a repeat write, not a conflict.
That's real idempotency, but it's a narrower version than the spec
actually asks for. The full requirement is to reply OK only when the
existing volume matches the new request's capacity, capabilities, and
parameters, and reply ALREADY_EXISTS (gRPC code 6) when the name
matches but something else about the request doesn't. This driver has
no way to ask that second question yet — a bare directory on disk
doesn't remember what capacity or parameters it was created with, only
that it exists.
Chapter 6 already faced this exact question for NodePublishVolume,
and answered it with a small sidecar metadata file: record what a call
actually did, and compare a later call against that record instead of
trusting a side effect alone. The same idea would close this gap here,
too. It isn't applied yet, on purpose — Chapter 9 points a real
spec-conformance tool, csi-sanity, straight at this exact gap, and
it's worth watching a real tool catch a real gap before reaching for
the fix on suspicion alone. CreateVolume gets its own metadata file
in Chapter 9, once that confirmation is in hand. Worth knowing the gap
is there in the meantime, even though nothing in this book's test
suite exercises it yet.
DeleteVolume, test-first
Same three-cycle rhythm as NodeUnpublishVolume in Chapter 6:
validation, the actual removal, then idempotency. DeleteVolumeRequest
only has two fields, volume_id and secrets, and only volume_id
needs checking.
func TestDeleteVolume_Validation(t *testing.T) {
d := newTestDriverInTempDir(t)
_, err := d.DeleteVolume(t.Context(), &csi.DeleteVolumeRequest{})
requireStatusCode(t, err, codes.InvalidArgument, &csi.DeleteVolumeRequest{})
}
Add "google.golang.org/grpc/codes" to controller_test.go's imports —
requireStatusCode itself wraps status.Code internally, so nothing here
needs "google.golang.org/grpc/status" directly, unlike the pre-refactor
version of this test.
go test ./internal/driver/...
--- FAIL: TestDeleteVolume_Validation (0.00s)
controller_test.go:110: code = Unimplemented, want InvalidArgument (req=&{VolumeId: Secrets:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.006s
FAIL
This one lands back on controller_test.go itself, not
testing_test.go — a single direct call to requireStatusCode, not one
routed through runValidation's closure, so the t.Helper() skip lands
on this test's own line, exactly the way it did for the single-case
TestNodePublishVolume_VolumeNotFound-style calls in Chapter 6.
func (d *Driver) DeleteVolume(
ctx context.Context,
req *csi.DeleteVolumeRequest,
) (*csi.DeleteVolumeResponse, error) {
if req.GetVolumeId() == "" {
return nil, status.Error(codes.InvalidArgument, "volume_id is required")
}
return &csi.DeleteVolumeResponse{}, nil
}
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.002s
Green. DeleteVolumeResponse is intentionally empty — unlike
CreateVolumeResponse, the spec gives it no fields at all, so there's
nothing a stub could get subtly wrong the way CreateVolume's nil
Volume could.
Now the actual removal:
func TestDeleteVolume_RemovesTheVolume(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
d := newTestDriver(t, dataDir, nil)
req := &csi.DeleteVolumeRequest{VolumeId: "vol-1"}
if _, err := d.DeleteVolume(t.Context(), req); err != nil {
t.Fatalf("DeleteVolume() returned an error: %v", err)
}
if _, err := os.Stat(filepath.Join(dataDir, "vol-1")); !os.IsNotExist(err) {
t.Errorf("volume directory still exists after DeleteVolume")
}
}
makeVolumeDir is the same testing_test.go helper Chapter 6 introduced
for TestNodePublishVolume_MountsTheVolume — a fake volume directory is a
fake volume directory whether Node or Controller is the one about to act
on it, so there's nothing Controller-specific to write here at all.
go test ./internal/driver/...
--- FAIL: TestDeleteVolume_RemovesTheVolume (0.00s)
controller_test.go:125: volume directory still exists after DeleteVolume
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.007s
FAIL
path := filepath.Join(d.dataDir, req.GetVolumeId())
if err := os.RemoveAll(path); err != nil {
return nil, status.Errorf(codes.Internal, "deleting volume %q: %v", req.GetVolumeId(), err)
}
return &csi.DeleteVolumeResponse{}, nil
That replaces DeleteVolume's old stub return, right after the
validation check.
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Green. Last case:
func TestDeleteVolume_Idempotent(t *testing.T) {
d := newTestDriverInTempDir(t)
req := &csi.DeleteVolumeRequest{VolumeId: "does-not-exist"}
if _, err := d.DeleteVolume(t.Context(), req); err != nil {
t.Fatalf("DeleteVolume() for an already-gone volume returned an error: %v", err)
}
}
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.003s
Also already green, and it's the exact same lesson NodeUnpublishVolume
taught in Chapter 6, on the opposite side of a volume's lifecycle:
os.RemoveAll doesn't error when the path it's asked to remove doesn't
exist. The spec's requirement here is actually simpler than
CreateVolume's — MUST reply 0 OK if the volume doesn't exist, full
stop, no capacity or parameter comparison to get right — and this
driver gets it for free from the exact same standard-library behavior
that made deleting an already-unmounted target free in Chapter 6.
Wiring ControllerServer into main.go
One line, alongside the existing RegisterIdentityServer and
RegisterNodeServer calls:
server := grpc.NewServer()
csi.RegisterIdentityServer(server, d)
csi.RegisterControllerServer(server, d)
csi.RegisterNodeServer(server, d)
reflection.Register(server)
Nothing else in main.go changes. The same d — one Driver, one
*mount.New(""), one dataDir — satisfies all three services, and this
line is the entire cost of exposing a third one over the same socket.
It's worth pausing on that "same socket" — this chapter's Controller
Deployment and Chapter 4/5/6's Node DaemonSet both 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. A real, larger CSI driver sometimes splits Controller and
Node into genuinely separate binaries, often to keep a Controller image
free of node-local dependencies a Deployment will never need — but
nothing about the CSI spec requires that split, and localdir-csi
doesn't bother with it.
deploy/controller.yaml
Chapter 2's project layout sketched this file as "driver + provisioner +
attacher sidecars" — a reasonable guess at the time, but only partly
right, as Chapter 8 explains once ControllerPublishVolume exists to
look at. deploy/csidriver.yaml already set attachRequired: false in
Chapter 4, and Chapter 8, not this one, is where ControllerPublishVolume
gets implemented. For now, controller.yaml needs exactly one sidecar:
external-provisioner, the component that turns
PersistentVolumeClaims into CreateVolume calls.
Create deploy/controller.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: localdir-csi-controller
labels:
app: localdir-csi-controller
spec:
replicas: 1
selector:
matchLabels:
app: localdir-csi-controller
template:
metadata:
labels:
app: localdir-csi-controller
spec:
serviceAccountName: localdir-csi-controller
containers:
- name: localdir-csi
image: localdir-csi:dev
imagePullPolicy: IfNotPresent
args:
- -endpoint=unix:///csi/csi.sock
volumeMounts:
- name: socket-dir
mountPath: /csi
- name: data-dir
mountPath: /data
- name: external-provisioner
image: registry.k8s.io/sig-storage/csi-provisioner:v6.3.0
args:
- --v=2
- --csi-address=/csi/csi.sock
volumeMounts:
- name: socket-dir
mountPath: /csi
volumes:
- name: socket-dir
emptyDir: {}
- name: data-dir
hostPath:
path: /var/lib/localdir-csi/data
type: DirectoryOrCreate
A few things that are different from node.yaml, on purpose:
socket-dir is an emptyDir, not a hostPath. Chapter 4's DaemonSet
needed hostPath specifically so kubelet — a process running directly
on the node, entirely outside Kubernetes — could reach the socket too.
Nothing outside this pod ever needs to reach the Controller's socket;
external-provisioner is a container in the same pod, so a socket that
only has to be visible to two containers sharing one pod is exactly
what emptyDir is for, no host filesystem involved at all.
data-dir still uses hostPath, at the identical path
/var/lib/localdir-csi/data the Node DaemonSet mounts. That's not
incidental — CreateVolume and NodePublishVolume both compute a
volume's directory as filepath.Join(dataDir, volumeID), and for
NodePublishVolume to find a directory CreateVolume created, both
have to agree on where dataDir actually is on disk. On the single-node
kind cluster this book deploys to, that's automatic — Controller and
every Node pod land on the one same machine, so the same host path is
the same directory no matter which pod wrote to it. On a real multi-node
cluster, that assumption breaks completely: a Controller pod scheduled
on node A creating a directory has done nothing for a pod trying to
publish that volume from node B, since /var/lib/localdir-csi/data on
two different nodes are two entirely different directories. This is
localdir-csi's central, load-bearing simplification, not a small
detail — it's why the driver's name starts with "local." A real
network-backed driver, EBS or NFS or anything else with data reachable
from more than one node, doesn't have this problem, because its
CreateVolume doesn't write to any one node's local disk to begin with.
No --leader-election flag. external-provisioner supports running
multiple replicas with one active leader at a time, coordinated through
Kubernetes Lease objects, for driver deployments that want high
availability. That needs its own RBAC rule and its own flag, and neither
is worth adding for a single-replica Deployment where there's only ever
one instance to begin with — a lock with no contender to lock out.
No privileged, no mountpoint-dir. Everything node.yaml's
securityContext and extra hostPath volumes exist for — performing a
real bind mount, seeing kubelet's own pod directories — is Node-service
territory. CreateVolume and DeleteVolume only ever call os.MkdirAll
and os.RemoveAll against an ordinary mounted directory; there's
nothing here that needs elevated privileges.
RBAC: the first Kubernetes-API-calling container in this book
Every container deployed so far — localdir-csi itself, and
node-driver-registrar — has never made a single call to the Kubernetes
API. node-driver-registrar only ever talks to a local socket and to
kubelet, a process on the same machine, neither of which needs
Kubernetes credentials. external-provisioner is different: watching
PersistentVolumeClaims cluster-wide, and creating PersistentVolumes
in response, both go through the real Kubernetes API server, which means
external-provisioner needs to authenticate as something, and that
something needs specific permission to read and write specific object
types.
A ServiceAccount is that "something" — an identity a pod can run as,
distinct from any human user's own credentials. A ClusterRole lists
what actions are allowed on which resource types, cluster-wide rather
than scoped to one namespace, since PersistentVolumeClaims can live in
any namespace. A ClusterRoleBinding connects the two — "this
ServiceAccount gets these permissions" — because a ClusterRole on
its own grants nothing to anyone until something binds it to an
identity.
Create deploy/controller-rbac.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: localdir-csi-controller
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: localdir-csi-provisioner
rules:
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch", "create", "patch", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["list", "watch", "create", "update", "patch"]
- apiGroups: ["storage.k8s.io"]
resources: ["csinodes"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: localdir-csi-provisioner
subjects:
- kind: ServiceAccount
name: localdir-csi-controller
namespace: default
roleRef:
kind: ClusterRole
name: localdir-csi-provisioner
apiGroup: rbac.authorization.k8s.io
This is a trimmed version of external-provisioner's own published RBAC
rules — real CSI sidecars ship a reference manifest listing exactly what
they need, and the honest way to write this file is to start from that
reference and remove what a specific driver's feature set doesn't use,
not to guess. Two categories are already gone here on purpose:
snapshot-related rules (volumesnapshots, volumesnapshotcontents) are
absent because localdir-csi doesn't implement CreateSnapshot, and
volumeattachments is absent because that resource only matters for
drivers with the PUBLISH_UNPUBLISH_VOLUME capability — the one
ControllerGetCapabilities deliberately doesn't list, since
ControllerPublishVolume isn't implemented until Chapter 8. Namespace-
scoped rules for leases and csistoragecapacities are also absent, for
the same reason --leader-election is absent from controller.yaml:
nothing here uses either feature.
patch on persistentvolumes earns its own callout, since it's easy
to trim by mistake — a plain create/delete pair looks complete, but
isn't. Modern external-provisioner adds a finalizer to every PV it
creates, and only removes that finalizer once DeleteVolume has
actually succeeded. Removing a finalizer is a small, partial edit to
an existing object — exactly what patch is for, not update, which
would mean sending the whole object back. Without patch,
external-provisioner can still create and delete volumes just fine,
right up until a PersistentVolumeClaim actually gets deleted — at
that point it gets stuck retrying forever, unable to clear its own
finalizer, and the real symptom is an RBAC "forbidden" error naming
patch specifically, not delete. persistentvolumeclaims needs
update for a related reason: to write back its own status on the
claim as provisioning progresses, not to delete anything.
namespace: default in the ClusterRoleBinding's subjects matters:
it has to match whatever namespace controller.yaml's Deployment
actually runs in — the ServiceAccount a pod uses is namespaced, even
though the permissions it's bound to, via ClusterRole, are not.
Deploying
make deploy
kubectl get pods -l app=localdir-csi-controller
NAME READY STATUS RESTARTS AGE
localdir-csi-controller-6f7d8b9c4d-x7k2p 2/2 Running 0 14s
2/2, the same signal Chapter 4 first explained — two containers,
localdir-csi and external-provisioner, both up. Check what
external-provisioner itself thinks of the driver it's sitting next to:
kubectl logs -l app=localdir-csi-controller -c external-provisioner --tail=20
I0820 12:00:01.442851 1 csi-provisioner.go:159] Version: v6.3.0
I0820 12:00:01.443988 1 connection.go:246] Connecting to unix:///csi/csi.sock
I0820 12:00:01.612300 1 common.go:143] Probing CSI driver for readiness
I0820 12:00:01.615117 1 csi-provisioner.go:222] Detected CSI driver localdir.csi.example.com
I0820 12:00:01.615204 1 controller.go:833] Starting provisioner controller localdir.csi.example.com_localdir-csi-controller-6f7d8b9c4d-x7k2p_...
Detected CSI driver localdir.csi.example.com is external-provisioner
successfully calling GetPluginInfo, from Chapter 3, over the shared
socket. Nothing about CREATE_DELETE_VOLUME shows up explicitly in this
log — it's checked silently, and its absence would show up not as an
error here, but as external-provisioner simply never attempting a
CreateVolume call later, the same "quiet failure" shape Chapter 4
already warned --kubelet-registration-path mistakes produce.
Proving it, with grpcurl
Same tool as Chapter 6, but it has to reach the driver a different way
this time. Chapter 6's grpcurl lived on the kind node itself,
because the Node service's socket sat on a hostPath — a real
directory on that same node's disk. The Controller's socket is
deliberately an emptyDir instead, visible only to containers inside
its own pod, which means grpcurl needs to run inside that pod, not
on the node next to it.
kubectl cp copies a file straight into a running container, using the
exact same grpcurl-linux binary Chapter 6 already pulled out of
grpcurl's official image:
kubectl cp ./grpcurl-linux \
$(kubectl get pod -l app=localdir-csi-controller -o jsonpath='{.items[0].metadata.name}'):/tmp/grpcurl \
-c localdir-csi
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- chmod +x /tmp/grpcurl
The $(kubectl get pod ...) substitution is only there because
kubectl cp, unlike kubectl exec, doesn't understand a deploy/name
shorthand — it needs one specific pod's actual name, so this looks it up
by the same app label the Deployment's own pod template sets.
Now call it, with -c localdir-csi naming which of the pod's two
containers to exec into, the same way logs -c external-provisioner
did above:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"name": "test-vol-1",
"capacity_range": {"required_bytes": 1048576},
"volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}' unix:///csi/csi.sock csi.v1.Controller/CreateVolume
{
"volume": {
"capacityBytes": "1048576",
"volumeId": "test-vol-1"
}
}
The same volume ID as the data-dir bind mount from Chapter 6's
hello.txt proof, but arriving from the opposite direction — this
time, localdir-csi created the directory, instead of assuming it
already existed:
docker exec csi-dev-control-plane ls /var/lib/localdir-csi/data/
test-vol-1
Call it again — the exact same request, unchanged:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"name": "test-vol-1",
"capacity_range": {"required_bytes": 1048576},
"volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}' unix:///csi/csi.sock csi.v1.Controller/CreateVolume
{
"volume": {
"capacityBytes": "1048576",
"volumeId": "test-vol-1"
}
}
Identical response, 0 OK, not an error — the idempotency
TestCreateVolume_Idempotent proved with a fake dataDir now proved
again for real, against an actual gRPC socket in an actual kind node.
Now delete it:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"volume_id": "test-vol-1"
}' unix:///csi/csi.sock csi.v1.Controller/DeleteVolume
{}
docker exec csi-dev-control-plane ls /var/lib/localdir-csi/data/
Empty — gone. And once more, against a volume ID that's already gone:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"volume_id": "test-vol-1"
}' unix:///csi/csi.sock csi.v1.Controller/DeleteVolume
{}
Still 0 OK — TestDeleteVolume_Idempotent's claim, proved the same
way.
What you should have now
internal/driver/driver.go:Drivernow embeds all threeUnimplemented*Serverstubs and satisfies all three CSI service interfaces, with no change toNewDriver's signature at allinternal/driver/controller.go:ControllerGetCapabilitiesadvertisingCREATE_DELETE_VOLUME, andCreateVolume/DeleteVolume, both idempotent — one by explicit design, one for free fromos.MkdirAllandos.RemoveAll's own behaviorcmd/localdir-csi/main.goregisteringcsi.ControllerServerover the same socket and the sameDrivervalue as Identity and Nodedeploy/controller.yaml: a single-replica Deployment runninglocaldir-csialongside external-provisioner, sharing a socket overemptyDirrather thanhostPath, and mounting the samedata-direvery Node pod doesdeploy/controller-rbac.yaml: the firstServiceAccount,ClusterRole, andClusterRoleBindingthis book has needed, scoped to exactly what external-provisioner's actual feature set here requires- Direct, hands-on proof —
grpcurlcalls against a real Controller pod — thatCreateVolumeandDeleteVolumeboth work, and that calling either one twice is safe
Nothing in this chapter has been triggered by an actual
PersistentVolumeClaim yet — every call so far has been grpcurl,
typed by hand, the same bridge Chapter 6 used before a real pod ever
requested a volume. Chapter 8 closes that gap: a PersistentVolumeClaim
that external-provisioner notices on its own, a PersistentVolume it
creates in response, and a pod that mounts it — the whole pipeline, end
to end, with nothing typed into grpcurl at all.