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 runValidationrunValidation 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 OKTestDeleteVolume_Idempotent's claim, proved the same way.

What you should have now

  • internal/driver/driver.go: Driver now embeds all three Unimplemented*Server stubs and satisfies all three CSI service interfaces, with no change to NewDriver's signature at all
  • internal/driver/controller.go: ControllerGetCapabilities advertising CREATE_DELETE_VOLUME, and CreateVolume/DeleteVolume, both idempotent — one by explicit design, one for free from os.MkdirAll and os.RemoveAll's own behavior
  • cmd/localdir-csi/main.go registering csi.ControllerServer over the same socket and the same Driver value as Identity and Node
  • deploy/controller.yaml: a single-replica Deployment running localdir-csi alongside external-provisioner, sharing a socket over emptyDir rather than hostPath, and mounting the same data-dir every Node pod does
  • deploy/controller-rbac.yaml: the first ServiceAccount, ClusterRole, and ClusterRoleBinding this book has needed, scoped to exactly what external-provisioner's actual feature set here requires
  • Direct, hands-on proof — grpcurl calls against a real Controller pod — that CreateVolume and DeleteVolume both 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.