Chapter 10: Snapshots
A whiteboard sits in a conference room, covered in a diagram someone's been erasing and redrawing all week. Photocopy it right now, and the copy is fixed forever — exactly what the board looked like at 2:14pm on Tuesday. Erase half the board on Wednesday. Draw something completely different. The photocopy doesn't know. It never will. It isn't connected to the whiteboard anymore — it never was, really. It's paper, holding a pattern of light and dark that used to match a whiteboard, at one specific moment.
That's what CreateSnapshot does to a volume. A photocopy, not a link.
What a snapshot actually is, and what it's for
A PersistentVolume, once bound, just keeps existing — read from,
written to, mounted into whatever pod needs it next, with no built-in
notion of "before" or "after." A snapshot cuts across that. It freezes
one moment of a volume's contents into a new, independent object, one
that keeps existing even after the original volume changes, or gets
deleted entirely.
Two real reasons that matters, both worth naming plainly. Backup: take
a snapshot before a risky migration, and a bad migration has an exact
undo point, not "whatever the last full backup happened to catch."
Cloning: hand a snapshot to CreateVolume as a starting point (the
volume_content_source field Chapter 7 already listed among
CreateVolumeRequest's fields, and left untouched) and a brand-new
volume starts out with another volume's exact contents, rather than
empty — a fast way to spin up a database replica or a test environment
seeded with real-looking data.
CSI's answer to both is CreateSnapshot and DeleteSnapshot, the two
RPCs this chapter implements, plus a whole extra sidecar and two more
Kubernetes CRDs to trigger them automatically — more new machinery in
one chapter than Chapter 8 needed, because unlike attaching, snapshots
aren't a "this driver happens not to need it" gap. localdir-csi's
volumes are just directories, and directories can absolutely be copied.
This is real, useful work for a toy driver to do.
Driver needs nothing new
Say it the way Chapter 7 said it about NewDriver: nothing here
changes. Driver has embedded csi.UnimplementedControllerServer
since Chapter 7, and ControllerServer's interface has always included
CreateSnapshot, DeleteSnapshot, and ListSnapshots — Chapter 9's
ValidateVolumeCapabilities work already made Driver satisfy the
full interface, stubs and all. CreateSnapshot needs exactly one thing
Driver already has: dataDir, the same field CreateVolume has
relied on since Chapter 7. No new constructor parameter, no new
embedded interface, no second red/green cycle just to catch up.
What CreateSnapshotRequest asks for, and what a Snapshot has to remember
CreateSnapshotRequest carries source_volume_id and name as its
two REQUIRED fields — a snapshot always names both the volume it's
copying and the name a CO wants to identify it by, the exact same
name/capabilities shape CreateVolumeRequest asked for back in Chapter
7. secrets and parameters exist too, unused here, same as
CreateVolumeRequest's own unused fields.
The response side is where snapshots ask for more than a volume did. A
Snapshot carries snapshot_id, source_volume_id, size_bytes,
ready_to_use, and creation_time — a real timestamp, not just an
identifier. CreateVolume never had to remember anything like that
about a volume; a directory's existence was the entire fact worth
tracking. A snapshot needs history: which volume it came from, and
when the copy was taken, permanently — not just for one response, but
for every later CreateSnapshot call asking about the same name.
That's the same shape of problem Chapter 7 named honestly — "a bare
directory on disk doesn't remember what capacity or parameters it was
created with, only that it exists" — and Chapter 9's real csi-sanity
run went on to confirm for real. CreateSnapshot doesn't get to leave
that gap open. Answering source_volume_id and creation_time
correctly on a second call requires persisting something beyond a
directory's mere presence — so this chapter reaches for the same
sidecar-metadata-file technique Chapter 6 introduced for
NodePublishVolume, and Chapter 9 just gave CreateVolume, applying
it here from day one instead of discovering the need for it the hard
way.
CreateSnapshot, test-first: validation
Same rhythm as every RPC so far. Add this to
internal/driver/controller_test.go:
func TestCreateSnapshot_Validation(t *testing.T) {
cases := []validationCase[*csi.CreateSnapshotRequest]{
{
name: "missing source volume id",
req: &csi.CreateSnapshotRequest{
Name: "snap-1",
},
},
{
name: "missing name",
req: &csi.CreateSnapshotRequest{
SourceVolumeId: "vol-1",
},
},
}
runValidation(t, cases, newTestDriverInTempDir,
func(t *testing.T, d *Driver, req *csi.CreateSnapshotRequest) error {
_, err := d.CreateSnapshot(t.Context(), req)
return err
},
)
}
Same validationCase[T]/runValidation pair every validation table
since Chapter 6 has used.
go test ./internal/driver/...
--- FAIL: TestCreateSnapshot_Validation (0.00s)
--- FAIL: TestCreateSnapshot_Validation/missing_source_volume_id (0.00s)
testing_test.go:92: code = Unimplemented, want InvalidArgument (req=&{SourceVolumeId: Name:snap-1 Secrets:map[] Parameters:map[]})
--- FAIL: TestCreateSnapshot_Validation/missing_name (0.00s)
testing_test.go:92: code = Unimplemented, want InvalidArgument (req=&{SourceVolumeId:vol-1 Name: Secrets:map[] Parameters:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.006s
FAIL
Unimplemented — the promoted stub talking, same as every RPC's first
validation test since Chapter 6: red for the right reason,
CreateSnapshot doesn't exist yet. And, as in every validation table
since Chapter 6, both failures land on testing_test.go:92 — one line
further down than Chapter 9's testing_test.go:85, since
mountCapabilityWithMode now sits between requireStatusCode and the
rest of the file — not on this test's own call to runValidation.
Add the method to internal/driver/controller.go:
func (d *Driver) CreateSnapshot(
ctx context.Context,
req *csi.CreateSnapshotRequest,
) (*csi.CreateSnapshotResponse, error) {
if req.GetSourceVolumeId() == "" {
return nil, status.Error(codes.InvalidArgument, "source_volume_id is required")
}
if req.GetName() == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
return &csi.CreateSnapshotResponse{Snapshot: &csi.Snapshot{}}, nil
}
=== RUN TestCreateSnapshot_Validation
--- PASS: TestCreateSnapshot_Validation (0.00s)
Green. That stub return is deliberately not nil, nil — the exact same
reason Chapter 7 gave CreateVolume's own stub a real, empty *Volume
instead of a missing one: CreateSnapshotResponse's snapshot field is
REQUIRED, and a nil one is a broken response waiting to panic the next
test that reads a field off it.
CreateSnapshot, test-first: the source volume has to exist
CreateSnapshot naming a volume that was never created — a typo, a
stale reference, a volume already deleted — is a real case, the same
shape ControllerPublishVolume already had to handle in Chapter 8. Add
this to internal/driver/controller_test.go:
func TestCreateSnapshot_SourceVolumeNotFound(t *testing.T) {
d := newTestDriverInTempDir(t)
req := &csi.CreateSnapshotRequest{
SourceVolumeId: "does-not-exist",
Name: "snap-1",
}
_, err := d.CreateSnapshot(t.Context(), req)
requireStatusCode(t, err, codes.NotFound, req)
}
--- FAIL: TestCreateSnapshot_SourceVolumeNotFound (0.00s)
controller_test.go:376: code = OK, want NotFound (req=&{SourceVolumeId:does-not-exist Name:snap-1 Secrets:map[] Parameters:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.007s
FAIL
Red — nothing checks the source volume exists yet. Same
filepath.Join(d.dataDir, ...)-and-os.Stat pattern this book has
reused since NodePublishVolume. Add it to CreateSnapshot, in
internal/driver/controller.go, right after the validation checks:
sourcePath := filepath.Join(d.dataDir, req.GetSourceVolumeId())
if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
return nil, status.Errorf(codes.NotFound, "source volume %q not found", req.GetSourceVolumeId())
} else if err != nil {
return nil, status.Errorf(codes.Internal, "checking source volume %q: %v", req.GetSourceVolumeId(), err)
}
=== RUN TestCreateSnapshot_SourceVolumeNotFound
--- PASS: TestCreateSnapshot_SourceVolumeNotFound (0.00s)
Green.
CreateSnapshot, test-first: actually creating one
The real work. A valid request against a real volume should copy that
volume's files somewhere new, and hand back a Snapshot describing
what it copied. Add this to internal/driver/controller_test.go:
func TestCreateSnapshot_CreatesTheSnapshot(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
if err := os.WriteFile(filepath.Join(dataDir, "vol-1", "hello.txt"), []byte("hello from vol-1"), 0640); err != nil {
t.Fatalf("writing fake volume data: %v", err)
}
d := newTestDriver(t, dataDir, nil)
req := &csi.CreateSnapshotRequest{
SourceVolumeId: "vol-1",
Name: "snap-1",
}
resp, err := d.CreateSnapshot(t.Context(), req)
if err != nil {
t.Fatalf("CreateSnapshot() returned an error: %v", err)
}
snap := resp.GetSnapshot()
if snap.GetSnapshotId() != "snap-1" {
t.Errorf("SnapshotId = %q, want %q", snap.GetSnapshotId(), "snap-1")
}
if snap.GetSourceVolumeId() != "vol-1" {
t.Errorf("SourceVolumeId = %q, want %q", snap.GetSourceVolumeId(), "vol-1")
}
if !snap.GetReadyToUse() {
t.Error("ReadyToUse = false, want true")
}
if snap.GetSizeBytes() != int64(len("hello from vol-1")) {
t.Errorf("SizeBytes = %d, want %d", snap.GetSizeBytes(), len("hello from vol-1"))
}
if snap.GetCreationTime() == nil {
t.Error("CreationTime is nil, want a real timestamp")
}
got, err := os.ReadFile(filepath.Join(dataDir, "snapshots", "snap-1", "hello.txt"))
if err != nil {
t.Fatalf("reading copied snapshot data: %v", err)
}
if string(got) != "hello from vol-1" {
t.Errorf("copied snapshot data = %q, want %q", got, "hello from vol-1")
}
}
--- FAIL: TestCreateSnapshot_CreatesTheSnapshot (0.00s)
controller_test.go:399: SnapshotId = "", want "snap-1"
controller_test.go:402: SourceVolumeId = "", want "vol-1"
controller_test.go:405: ReadyToUse = false, want true
controller_test.go:408: SizeBytes = 0, want 16
controller_test.go:411: CreationTime is nil, want a real timestamp
controller_test.go:416: reading copied snapshot data: open .../snapshots/snap-1/hello.txt: no such file or directory
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.007s
FAIL
Five separate assertions failing from one missing feature — worth reading as one red, not five. Nothing about the stub touches the filesystem or the response at all yet.
Fixing this needs three things: a place on disk for a snapshot's copied
files, a place on disk for a snapshot's metadata, and the copy itself.
Add these three helpers below CreateSnapshot in
internal/driver/controller.go:
// snapshotDataPath is where a snapshot's copied files live — a full copy of
// the source volume's directory, at the time CreateSnapshot ran.
func snapshotDataPath(dataDir, snapshotID string) string {
return filepath.Join(dataDir, "snapshots", snapshotID)
}
// snapshotMetaPath is where a snapshot's metadata lives — deliberately
// outside snapshotDataPath, the same reasoning behind every sidecar
// metadata file this book uses: metadata must never end up inside a
// directory that later gets bind-mounted or copied again as volume data.
func snapshotMetaPath(dataDir, snapshotID string) string {
return filepath.Join(dataDir, ".csi-meta", "snapshots", snapshotID+".json")
}
// dirSize adds up the size of every regular file under path — the closest
// thing this toy driver has to asking a real backend how large a snapshot
// turned out to be.
func dirSize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
return total, err
}
dirSize walks the whole snapshot directory, not just its top
level. filepath.WalkDir visits every entry under path — files
and directories both — going arbitrarily deep, however many
subdirectories the source volume happens to have. A volume with
three levels of nested folders gets all three levels walked;
dirSize doesn't know or care how deep it goes, and doesn't need
to be told.
The callback answers one question per entry: is this a file?
Directories return early — d.IsDir() — because a directory has no
size of its own worth counting; only the files inside it do. Every
regular file's size gets added to total, via d.Info().Size().
Skip a directory, add up a file: that's the whole function, run
once per entry in the tree.
One more detail worth naming: the first error WalkDir hits — a
permission problem, a broken symlink, anything — gets returned
straight up and stops the walk immediately. dirSize never adds up
a partial answer. A snapshot's reported size is either complete and
correct, or CreateSnapshot fails and reports nothing at all — see
the Internal error a few lines below, once this helper is wired
in.
snapshotMeta, alongside them, is the piece actually worth pausing
on: the data structure CreateSnapshot writes now, and reads back
later. Add this too:
// snapshotMeta is the metadata CreateSnapshot records for each snapshot it
// creates, so a later CreateSnapshot call for the same name can tell a
// genuine idempotent retry (same source volume) apart from a real conflict
// (different source volume).
type snapshotMeta struct {
SourceVolumeID string `json:"source_volume_id"`
CreationTime time.Time `json:"creation_time"`
SizeBytes int64 `json:"size_bytes"`
}
func writeSnapshotMeta(dataDir, snapshotID string, meta snapshotMeta) error {
metaBytes, err := json.Marshal(meta)
if err != nil {
return err
}
metaPath := snapshotMetaPath(dataDir, snapshotID)
if err := os.MkdirAll(filepath.Dir(metaPath), 0o750); err != nil {
return err
}
return os.WriteFile(metaPath, metaBytes, 0o640)
}
func readSnapshotMeta(dataDir, snapshotID string) (snapshotMeta, error) {
var meta snapshotMeta
data, err := os.ReadFile(snapshotMetaPath(dataDir, snapshotID))
if err != nil {
return meta, err
}
err = json.Unmarshal(data, &meta)
return meta, err
}
Three fields, three jobs. SourceVolumeID is what makes
idempotency and conflict-detection possible: a retry of
CreateSnapshot for the same name only counts as the same snapshot
if the source volume matches too — exactly the check CreateSnapshot
makes a few lines below, once this struct exists to check against.
CreationTime is captured once, the moment the snapshot is made,
and never touched again; read it back a year later and it still
says when the snapshot was actually created, not when someone last
happened to ask. SizeBytes is dirSize's answer, cached at
creation time rather than recomputed on every read — walking a
directory tree on every question a caller might ask would get
expensive once the driver is holding many snapshots, so
CreateSnapshot pays that cost exactly once and writes the answer
down.
writeSnapshotMeta and readSnapshotMeta are the same shape
you've already seen: marshal snapshotMeta to JSON, write it under
.csi-meta/, and on the way back in, read the bytes and unmarshal
them into the same struct. Nothing new in the mechanics — this is
Chapter 7's capacity-metadata pattern, reused for a second kind of
metadata.
Add "encoding/json", "io/fs", and "time" to controller.go's
imports.
Now the copy itself, replacing CreateSnapshot's old stub return, in
internal/driver/controller.go:
destPath := snapshotDataPath(d.dataDir, req.GetName())
if err := os.CopyFS(destPath, os.DirFS(sourcePath)); err != nil {
return nil, status.Errorf(codes.Internal, "copying volume %q into snapshot %q: %v", req.GetSourceVolumeId(), req.GetName(), err)
}
size, err := dirSize(destPath)
if err != nil {
return nil, status.Errorf(codes.Internal, "measuring snapshot %q: %v", req.GetName(), err)
}
meta := snapshotMeta{
SourceVolumeID: req.GetSourceVolumeId(),
CreationTime: time.Now().UTC(),
SizeBytes: size,
}
if err := writeSnapshotMeta(d.dataDir, req.GetName(), meta); err != nil {
return nil, status.Errorf(codes.Internal, "recording snapshot %q metadata: %v", req.GetName(), err)
}
return &csi.CreateSnapshotResponse{
Snapshot: &csi.Snapshot{
SnapshotId: req.GetName(),
SourceVolumeId: meta.SourceVolumeID,
SizeBytes: meta.SizeBytes,
CreationTime: timestamppb.New(meta.CreationTime),
ReadyToUse: true,
},
}, nil
Add "google.golang.org/protobuf/types/known/timestamppb" to
controller.go's imports — the same package the real generated csi
package already depends on for creation_time, since the spec defines
that field as a google.protobuf.Timestamp, not a plain string or
integer. timestamppb.New(t) builds one from an ordinary time.Time;
.AsTime() is its inverse, for whenever this driver needs to read a
Timestamp back as a time.Time (it doesn't, yet — nothing in this
chapter converts one back). The test above checks GetCreationTime() == nil rather than .AsTime().IsZero() on purpose: a real
*timestamppb.Timestamp's AsTime() converts a nil receiver to the
Unix epoch, not Go's zero time.Time{} — .IsZero() on that result is
false, not true, so it can't actually tell "never set" apart from "a
snapshot genuinely created at 1970-01-01." Checking the pointer for
nil is the one that's actually reliable.
os.CopyFS — new to this book, standard library since Go 1.23 — does
the entire recursive copy in one call: os.CopyFS(dir, fsys) copies
every file fsys contains into dir, creating dir itself if it
doesn't already exist. os.DirFS(sourcePath) turns an ordinary
directory into the fs.FS value CopyFS wants. Two standard-library
calls, no hand-rolled recursive walk needed — the same "no exotic
machinery" spirit this driver has held to since Chapter 1, now applying
to a slightly less trivial operation than MkdirAll.
go test ./internal/driver/...
=== RUN TestCreateSnapshot_CreatesTheSnapshot
--- PASS: TestCreateSnapshot_CreatesTheSnapshot (0.00s)
Green. ReadyToUse: true unconditionally is worth a direct word,
since the field name implies there could be a false case:
localdir-csi's "copy" is a single synchronous os.CopyFS call, done
by the time CreateSnapshot returns — there's no window where the
snapshot exists but isn't finished yet. A real backend snapshotting a
multi-terabyte volume asynchronously would legitimately return
ready_to_use: false at first, with ListSnapshots or a later poll
eventually reporting true once the backend finishes. This driver
never has that problem, so it never needs that field's honest range —
another small, real consequence of "local directories on the node," the
same simplification Chapter 7 already named as this driver's defining
one.
CreateSnapshot, test-first: idempotency, and a conflict caught from day one
Same two-part shape the spec asked of CreateVolume back in Chapter 7:
OK for a genuine retry, a real error for a genuine conflict. Add both
cases at once to internal/driver/controller_test.go:
func TestCreateSnapshot_Idempotent(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
if err := os.WriteFile(filepath.Join(dataDir, "vol-1", "hello.txt"), []byte("hello"), 0o640); err != nil {
t.Fatalf("writing fake volume data: %v", err)
}
d := newTestDriver(t, dataDir, nil)
req := &csi.CreateSnapshotRequest{
SourceVolumeId: "vol-1",
Name: "snap-1",
}
first, err := d.CreateSnapshot(t.Context(), req)
if err != nil {
t.Fatalf("first CreateSnapshot() returned an error: %v", err)
}
second, err := d.CreateSnapshot(t.Context(), req)
if err != nil {
t.Fatalf("second CreateSnapshot() for the same name returned an error: %v", err)
}
if second.GetSnapshot().GetSnapshotId() != first.GetSnapshot().GetSnapshotId() {
t.Errorf("SnapshotId = %q, want %q", second.GetSnapshot().GetSnapshotId(), first.GetSnapshot().GetSnapshotId())
}
if second.GetSnapshot().GetSourceVolumeId() != "vol-1" {
t.Errorf("SourceVolumeId = %q, want %q", second.GetSnapshot().GetSourceVolumeId(), "vol-1")
}
}
func TestCreateSnapshot_RejectsSourceVolumeMismatch(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
makeVolumeDir(t, dataDir, "vol-2")
d := newTestDriver(t, dataDir, nil)
first := &csi.CreateSnapshotRequest{
SourceVolumeId: "vol-1",
Name: "snap-1",
}
if _, err := d.CreateSnapshot(t.Context(), first); err != nil {
t.Fatalf("first CreateSnapshot() returned an error: %v", err)
}
second := &csi.CreateSnapshotRequest{
SourceVolumeId: "vol-2",
Name: "snap-1",
}
_, err := d.CreateSnapshot(t.Context(), second)
requireStatusCode(t, err, codes.AlreadyExists, second)
}
=== RUN TestCreateSnapshot_Idempotent
controller_test.go:443: second CreateSnapshot() for the same name returned an error: rpc error: code = Internal desc = copying volume "vol-1" into snapshot "snap-1": open /tmp/TestCreateSnapshot_Idempotent2485715020/001/snapshots/snap-1/hello.txt: file exists
--- FAIL: TestCreateSnapshot_Idempotent (0.00s)
=== RUN TestCreateSnapshot_RejectsSourceVolumeMismatch
controller_test.go:472: code = OK, want AlreadyExists (req=&{SourceVolumeId:vol-2 Name:snap-1 Secrets:map[] Parameters:map[]})
--- FAIL: TestCreateSnapshot_RejectsSourceVolumeMismatch (0.00s)
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.004s
FAIL
Both fail, but not for the same reason. The conflict case fails the
way you'd expect — no check exists yet, so a mismatched source volume
sails through as OK. The idempotency case fails for a sharper
reason, worth stating plainly: os.CopyFS is not idempotent-safe.
Standard library, since Go 1.23, and it refuses to overwrite a file
that already exists at the destination — call it twice with the same
source and destination and the second call fails with exactly the
error above, file exists. A source volume with real data in it makes
that collision certain on any genuine retry. TestCreateSnapshot_Idempotent
seeding an actual file, instead of leaving the volume empty, is what
surfaces this — an empty volume gives os.CopyFS nothing to collide
with on the second call, so the test would pass by accident and hide
the bug. That's the exact same lesson Chapter 9's real csi-sanity run
taught about CreateVolume's own idempotency gap: a test only proves
what it actually exercises.
Both gaps need the same fix: check the snapshot's own metadata before
touching the filesystem at all, and short-circuit on a genuine retry
instead of ever calling os.CopyFS a second time. Add the check to
CreateSnapshot, in internal/driver/controller.go, right after the
source-volume existence check and before the copy:
if existing, err := readSnapshotMeta(d.dataDir, req.GetName()); err == nil {
if existing.SourceVolumeID != req.GetSourceVolumeId() {
return nil, status.Errorf(codes.AlreadyExists, "snapshot %q already exists for a different source volume", req.GetName())
}
// Same name, same source volume: a genuine retry. Return the
// snapshot already on disk instead of falling through to
// os.CopyFS below — it refuses to overwrite files that already
// exist, so re-copying here would fail on every idempotent retry.
return &csi.CreateSnapshotResponse{
Snapshot: &csi.Snapshot{
SnapshotId: req.GetName(),
SourceVolumeId: existing.SourceVolumeID,
SizeBytes: existing.SizeBytes,
CreationTime: timestamppb.New(existing.CreationTime),
ReadyToUse: true,
},
}, nil
} else if !os.IsNotExist(err) {
return nil, status.Errorf(codes.Internal, "reading snapshot %q metadata: %v", req.GetName(), err)
}
=== RUN TestCreateSnapshot_Idempotent
--- PASS: TestCreateSnapshot_Idempotent (0.00s)
=== RUN TestCreateSnapshot_RejectsSourceVolumeMismatch
--- PASS: TestCreateSnapshot_RejectsSourceVolumeMismatch (0.00s)
Both green — for real reasons this time, not by accident of an empty test volume. Read the metadata once, decide from it: a matching source volume is a retry, answered from what's already recorded; a mismatched one is a real conflict, rejected; anything else falls through to do the actual copy, exactly once, ever, per snapshot name.
DeleteSnapshot, test-first
Same three-cycle shape as DeleteVolume in Chapter 7. Add to
internal/driver/controller_test.go:
func TestDeleteSnapshot_Validation(t *testing.T) {
d := newTestDriverInTempDir(t)
_, err := d.DeleteSnapshot(t.Context(), &csi.DeleteSnapshotRequest{})
requireStatusCode(t, err, codes.InvalidArgument, &csi.DeleteSnapshotRequest{})
}
--- FAIL: TestDeleteSnapshot_Validation (0.00s)
controller_test.go:479: code = Unimplemented, want InvalidArgument (req=&{SnapshotId: Secrets:map[]})
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.012s
FAIL
func (d *Driver) DeleteSnapshot(
ctx context.Context,
req *csi.DeleteSnapshotRequest,
) (*csi.DeleteSnapshotResponse, error) {
if req.GetSnapshotId() == "" {
return nil, status.Error(codes.InvalidArgument, "snapshot_id is required")
}
return &csi.DeleteSnapshotResponse{}, nil
}
=== RUN TestDeleteSnapshot_Validation
--- PASS: TestDeleteSnapshot_Validation (0.00s)
Green. Now the removal, and the idempotency case, both at once:
func TestDeleteSnapshot_RemovesTheSnapshot(t *testing.T) {
dataDir := t.TempDir()
makeVolumeDir(t, dataDir, "vol-1")
d := newTestDriver(t, dataDir, nil)
createReq := &csi.CreateSnapshotRequest{SourceVolumeId: "vol-1", Name: "snap-1"}
if _, err := d.CreateSnapshot(t.Context(), createReq); err != nil {
t.Fatalf("CreateSnapshot() returned an error: %v", err)
}
deleteReq := &csi.DeleteSnapshotRequest{SnapshotId: "snap-1"}
if _, err := d.DeleteSnapshot(t.Context(), deleteReq); err != nil {
t.Fatalf("DeleteSnapshot() returned an error: %v", err)
}
if _, err := os.Stat(filepath.Join(dataDir, "snapshots", "snap-1")); !os.IsNotExist(err) {
t.Errorf("snapshot data directory still exists after DeleteSnapshot")
}
if _, err := os.Stat(filepath.Join(dataDir, ".csi-meta", "snapshots", "snap-1.json")); !os.IsNotExist(err) {
t.Errorf("snapshot metadata still exists after DeleteSnapshot")
}
}
func TestDeleteSnapshot_Idempotent(t *testing.T) {
d := newTestDriverInTempDir(t)
req := &csi.DeleteSnapshotRequest{SnapshotId: "does-not-exist"}
if _, err := d.DeleteSnapshot(t.Context(), req); err != nil {
t.Fatalf("DeleteSnapshot() for an already-gone snapshot returned an error: %v", err)
}
}
--- FAIL: TestDeleteSnapshot_RemovesTheSnapshot (0.00s)
controller_test.go:498: snapshot data directory still exists after DeleteSnapshot
controller_test.go:501: snapshot metadata still exists after DeleteSnapshot
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.011s
FAIL
TestDeleteSnapshot_Idempotent passes immediately — os.RemoveAll's
already-gone-is-fine behavior, the same free property DeleteVolume
had in Chapter 7. The removal test doesn't. Fix it in
internal/driver/controller.go, replacing DeleteSnapshot's old stub
return:
if err := os.RemoveAll(snapshotDataPath(d.dataDir, req.GetSnapshotId())); err != nil {
return nil, status.Errorf(codes.Internal, "deleting snapshot %q: %v", req.GetSnapshotId(), err)
}
if err := os.Remove(snapshotMetaPath(d.dataDir, req.GetSnapshotId())); err != nil && !os.IsNotExist(err) {
return nil, status.Errorf(codes.Internal, "deleting snapshot %q metadata: %v", req.GetSnapshotId(), err)
}
return &csi.DeleteSnapshotResponse{}, nil
=== RUN TestDeleteSnapshot_RemovesTheSnapshot
--- PASS: TestDeleteSnapshot_RemovesTheSnapshot (0.00s)
=== RUN TestDeleteSnapshot_Idempotent
--- PASS: TestDeleteSnapshot_Idempotent (0.00s)
Both green.
ControllerGetCapabilities keeps the promise Chapter 7 made
Chapter 7 built ControllerGetCapabilities around a slice literal on
purpose, "specifically so that a later chapter adding LIST_VOLUMES or
GET_CAPACITY support means appending an entry, not restructuring this
method." This is that later chapter, just with a different capability
than the two named at the time. Add this test to
internal/driver/controller_test.go:
func TestControllerGetCapabilities_AdvertisesCreateDeleteSnapshot(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)
}
var found bool
for _, c := range resp.Capabilities {
if rpc := c.GetRpc(); rpc != nil && rpc.Type == csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT {
found = true
}
}
if !found {
t.Error("CREATE_DELETE_SNAPSHOT not found among advertised capabilities")
}
}
--- FAIL: TestControllerGetCapabilities_AdvertisesCreateDeleteSnapshot (0.00s)
controller_test.go:530: CREATE_DELETE_SNAPSHOT not found among advertised capabilities
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.012s
FAIL
Append the entry, in internal/driver/controller.go:
{
Type: &csi.ControllerServiceCapability_Rpc{
Rpc: &csi.ControllerServiceCapability_RPC{
Type: csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT,
},
},
},
That goes inside ControllerGetCapabilities's existing
Capabilities: []*csi.ControllerServiceCapability{...} slice, right
after the CREATE_DELETE_VOLUME entry Chapter 7 wrote. No
restructuring, exactly as promised — one entry, appended.
go test ./internal/driver/...
--- FAIL: TestControllerGetCapabilities (0.00s)
controller_test.go:21: got 2 capabilities, want 1
FAIL
FAIL github.com/yourname/localdir-csi/internal/driver 0.012s
FAIL
The promise held for the implementation — one entry, appended, no
restructuring. It didn't hold for the original test.
TestControllerGetCapabilities, written back in Chapter 7, hardcoded
len(resp.Capabilities) != 1, a check that was never going to survive
a second capability being added, appended or not. Worth being honest
about that rather than quietly patching around it: Open/Closed applied
to the method's own logic, not to every assertion a much earlier test
happened to make about its output. Fix the old test itself, in
internal/driver/controller_test.go:
if len(resp.Capabilities) == 0 {
t.Fatal("got 0 capabilities, want at least 1")
}
That replaces the old != 1 check. The rest of
TestControllerGetCapabilities — asserting Capabilities[0] is
specifically CREATE_DELETE_VOLUME — stays exactly as Chapter 7 wrote
it, since ControllerGetCapabilities still appends new entries after
the existing ones, never reorders them.
=== RUN TestControllerGetCapabilities
--- PASS: TestControllerGetCapabilities (0.00s)
=== RUN TestControllerGetCapabilities_AdvertisesCreateDeleteSnapshot
--- PASS: TestControllerGetCapabilities_AdvertisesCreateDeleteSnapshot (0.00s)
Both green, and the full suite:
go test ./internal/driver/... -v
ok github.com/yourname/localdir-csi/internal/driver 0.015s
What this chapter doesn't implement, on purpose
Two gaps, named directly, not glossed past.
ListSnapshots stays a bare Unimplemented stub, the same as
ControllerPublishVolume was before Chapter 8 gave it a real body —
except this time, unlike Chapter 8, nothing in this chapter gives it
one. ControllerGetCapabilities never advertises LIST_SNAPSHOTS
alongside CREATE_DELETE_SNAPSHOT, so any conformant CO, csi-sanity
included, treats it exactly the way Chapter 9 explained for every other
unadvertised RPC: a silent, deliberate skip, not a failure. A CO can
still create and delete snapshots by name without it; ListSnapshots
only matters for discovering snapshots a CO doesn't already know the
IDs of, a case this book's own demo never needs.
CreateVolume still doesn't read volume_content_source — the field
that would let a new volume start out as a copy of an existing
snapshot, the "cloning" half of this chapter's opening list of reasons
snapshots matter. CreateSnapshot and DeleteSnapshot cover the
"backup" half completely: a snapshot can be taken, inspected on disk,
and removed. Restoring from one — turning a snapshot back into a
usable volume — is real, additional work CreateVolume doesn't do,
and this book's outline doesn't schedule a return trip to it. Worth
knowing exactly where the line sits, rather than assuming "snapshots"
as a chapter title means the whole feature is done.
deploy/: the first cluster-level component this book has needed
Every sidecar so far — node-driver-registrar in Chapter 4,
external-provisioner in Chapter 7 — ships as a container inside one of
this driver's own pods, in deploy/node.yaml or
deploy/controller.yaml. Snapshots need one of those too, but they
also need something genuinely new: a piece of cluster machinery that
has nothing to do with localdir-csi specifically, installed once per
cluster, shared by every CSI driver that ever wants to support
snapshots.
That's the snapshot-controller — not a sidecar, and not part of
deploy/ at all. It watches VolumeSnapshot objects cluster-wide (the
CO-facing, namespaced request — "I want a snapshot of this claim," the
direct analog of a PersistentVolumeClaim) and, for each one, creates
a matching VolumeSnapshotContent object (the cluster-scoped record of
an actual snapshot, the direct analog of a PersistentVolume). It
never talks to any driver's socket directly. That's
csi-snapshotter's job — the actual sidecar, watching
VolumeSnapshotContent objects and turning them into the
CreateSnapshot/DeleteSnapshot gRPC calls this chapter just built,
the same relationship external-provisioner has to PersistentVolumes
and CreateVolume.
Three CRDs come with this split: VolumeSnapshotClass (the
StorageClass equivalent — names which driver handles a class of
snapshot), VolumeSnapshot, and VolumeSnapshotContent. None of them
exist in a stock Kubernetes cluster; they're part of what installing
the snapshot-controller sets up.
Install the CRDs and the snapshot-controller once, cluster-wide — not
part of make deploy, and not repeated for every driver a cluster ever
adds:
kubectl kustomize "https://github.com/kubernetes-csi/external-snapshotter/client/config/crd?ref=v8.2.0" | kubectl create -f -
kubectl -n kube-system kustomize "https://github.com/kubernetes-csi/external-snapshotter/deploy/kubernetes/snapshot-controller?ref=v8.2.0" | kubectl create -f -
kubectl get pods -n kube-system -l app.kubernetes.io/name=snapshot-controller
NAME READY STATUS RESTARTS AGE
snapshot-controller-7d8f6c9b5-k2n4p 1/1 Running 0 11s
That pod will sit there watching VolumeSnapshot objects regardless of
which drivers this cluster ever adds — genuinely shared infrastructure,
the first thing this book has deployed that isn't localdir-csi's own.
deploy/controller.yaml and deploy/controller-rbac.yaml: the third sidecar
csi-snapshotter, unlike snapshot-controller, is per-driver — it needs
this driver's own socket, so it belongs in deploy/controller.yaml
alongside localdir-csi and external-provisioner. Add a third
container:
- name: csi-snapshotter
image: registry.k8s.io/sig-storage/csi-snapshotter:v8.2.0
args:
- --v=2
- --csi-address=/csi/csi.sock
volumeMounts:
- name: socket-dir
mountPath: /csi
That block goes inside deploy/controller.yaml's existing containers:
list, after the external-provisioner entry Chapter 7 added — same
socket-dir emptyDir, same --csi-address flag shape, no new volume
needed. Three containers now sharing one socket: the driver itself, and
two sidecars each watching a different piece of cluster state and
translating it into calls against that same socket.
RBAC needs a real extension too, not a cosmetic one. Chapter 7 trimmed
external-provisioner's rules and said directly: "snapshot-related rules
(volumesnapshots, volumesnapshotcontents) are absent because
localdir-csi doesn't implement CreateSnapshot." That's no longer
true. Add a second ClusterRole and ClusterRoleBinding to
deploy/controller-rbac.yaml, trimmed from external-snapshotter's own
published reference RBAC the same honest way Chapter 7 trimmed
external-provisioner's:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: localdir-csi-snapshotter
rules:
- apiGroups: [""]
resources: ["events"]
verbs: ["list", "watch", "create", "update", "patch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshotcontents"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshotcontents/status"]
verbs: ["update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: localdir-csi-snapshotter
subjects:
- kind: ServiceAccount
name: localdir-csi-controller
namespace: default
roleRef:
kind: ClusterRole
name: localdir-csi-snapshotter
apiGroup: rbac.authorization.k8s.io
No new ServiceAccount — the binding's subjects names
localdir-csi-controller, the exact same one Chapter 7 already
created. serviceAccountName in deploy/controller.yaml's pod spec
only takes one value, and every container in a pod runs under that
same identity regardless of which sidecar happens to need which
specific permissions. One ServiceAccount, now bound to two
ClusterRoles instead of one — external-provisioner's from Chapter 7,
and csi-snapshotter's, both from deploy/controller-rbac.yaml — is
exactly how a pod with three containers and two different sidecars'
worth of Kubernetes API needs actually works.
Two categories missing from csi-snapshotter's own reference RBAC here,
gone on purpose, the same way Chapter 7 explained its own omissions
rather than leaving them silent. Volume group snapshot rules
(volumegroupsnapshotclasses, volumegroupsnapshotcontents) —
csi-snapshotter's published reference RBAC includes them, but
localdir-csi doesn't implement CREATE_DELETE_VOLUME_GROUP_SNAPSHOT,
a capability this book has never mentioned and doesn't start now.
Leader-election rules on leases — same reasoning Chapter 7 already
gave for skipping them on external-provisioner: a lock only earns its
keep with more than one replica contending for it, and
deploy/controller.yaml has exactly one.
deploy/volumesnapshotclass.yaml
The StorageClass equivalent for snapshots — the object that names
which driver a VolumeSnapshot actually resolves to:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: localdir-csi-snapshot
driver: localdir.csi.example.com
deletionPolicy: Delete
driver has to match this driver's own name exactly, the same
provisioner field rule Chapter 8's StorageClass already followed.
deletionPolicy: Delete mirrors StorageClass's own reclaimPolicy:
the underlying VolumeSnapshotContent — and, per this chapter's own
DeleteSnapshot, the actual copied directory on disk — gets deleted
automatically once its VolumeSnapshot is deleted.
Deploy everything:
make deploy
kubectl apply -f deploy/volumesnapshotclass.yaml
kubectl get pods -l app=localdir-csi-controller
NAME READY STATUS RESTARTS AGE
localdir-csi-controller-6f7d8b9c4d-x9m3q 3/3 Running 0 12s
3/3 — the same signal Chapter 4 and Chapter 7 both already explained, now with a third container in it.
Proving it, with grpcurl
Same kubectl cp/kubectl exec pattern as Chapters 7 and 8. First, a
real volume, with real data in it — grpcurl alone only ever proved
volumes exist, so this is the first proof in this book that reaches
past the socket onto the node's own disk to seed content directly,
before calling anything:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"name": "snap-source",
"capacity_range": {"required_bytes": 1048576},
"volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}' unix:///csi/csi.sock csi.v1.Controller/CreateVolume
docker exec csi-dev-control-plane sh -c \
'echo "hello from a real volume" > /var/lib/localdir-csi/data/snap-source/hello.txt'
Now snapshot it:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"source_volume_id": "snap-source",
"name": "snap-demo"
}' unix:///csi/csi.sock csi.v1.Controller/CreateSnapshot
{
"snapshot": {
"sizeBytes": "25",
"snapshotId": "snap-demo",
"sourceVolumeId": "snap-source",
"creationTime": "2026-08-22T16:09:22.689807357Z",
"readyToUse": true
}
}
Check the copy landed on the node's own disk, independent of anything the driver reported about itself:
docker exec csi-dev-control-plane cat /var/lib/localdir-csi/data/snapshots/snap-demo/hello.txt
hello from a real volume
Real data, really copied. Call CreateSnapshot again, identical
request:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"source_volume_id": "snap-source",
"name": "snap-demo"
}' unix:///csi/csi.sock csi.v1.Controller/CreateSnapshot
ERROR:
Code: Internal
Message: copying volume "snap-source" into snapshot "/data/snapshots/snap-demo": open /data/snapshots/snap-demo/hello.txt: file exists
command terminated with exit code 77
Not 0 OK — a real failure, against a real cluster, on the exact
retry this section calls "the same request." This is the
os.CopyFS-refuses-to-overwrite bug from the TDD walkthrough above,
caught live: the driver running in this cluster predated the
early-return fix, so a genuine retry fell through to a second copy and
collided with the file the first call had already written. The fix is
the one already added to internal/driver/controller.go above.
make deploy (Chapter 2) rebuilds the image, reloads it into the
cluster, and restarts both the Controller Deployment and the Node
DaemonSet on your behalf, so the already-running pod picks up the new
build without any extra command here:
make deploy
Call CreateSnapshot again, same request, against the redeployed
driver:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"source_volume_id": "snap-source",
"name": "snap-demo"
}' unix:///csi/csi.sock csi.v1.Controller/CreateSnapshot
{
"snapshot": {
"sizeBytes": "25",
"snapshotId": "snap-demo",
"sourceVolumeId": "snap-source",
"creationTime": "2026-08-22T16:09:22.689807357Z",
"readyToUse": true
}
}
0 OK — and worth reading closely: creationTime is the exact same
timestamp as the very first CreateSnapshot call above, down to the
nanosecond. The driver's data directory is a hostPath, so it
survived the redeploy — snap-demo's metadata was still sitting on
disk from before the fix. This call landed on the new early-return
branch, matched that recorded metadata, and returned it straight back
without ever touching os.CopyFS. Confirm the file itself was never
touched again either:
docker exec csi-dev-control-plane cat /var/lib/localdir-csi/data/snapshots/snap-demo/hello.txt
hello from a real volume
Same content, same one real copy. Call CreateSnapshot a third time,
same request, to prove the first retry wasn't a fluke:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"source_volume_id": "snap-source",
"name": "snap-demo"
}' unix:///csi/csi.sock csi.v1.Controller/CreateSnapshot
{
"snapshot": {
"sizeBytes": "25",
"snapshotId": "snap-demo",
"sourceVolumeId": "snap-source",
"creationTime": "2026-08-22T16:09:22.689807357Z",
"readyToUse": true
}
}
Identical again. TestCreateSnapshot_Idempotent, proved for real this
time, against the exact bug that first broke it. Now delete it:
kubectl exec deploy/localdir-csi-controller -c localdir-csi -- /tmp/grpcurl -plaintext -d '{
"snapshot_id": "snap-demo"
}' unix:///csi/csi.sock csi.v1.Controller/DeleteSnapshot
{}
docker exec csi-dev-control-plane ls /var/lib/localdir-csi/data/snapshots/
Empty — gone, the same proof-from-both-sides technique every earlier
chapter's grpcurl section has used.
Part two: a VolumeSnapshot object, watched and acted on automatically
Same closing move as Chapter 8's PersistentVolumeClaim section: prove
the whole loop runs without a single RPC typed by hand. Reuse
demo-pvc.yaml and demo-pod.yaml from Chapter 8 to get a real,
mounted, localdir-csi-backed volume with a pod actually writing to
it:
kubectl apply -f demo-pvc.yaml
kubectl apply -f demo-pod.yaml
kubectl exec demo-pod -- sh -c 'echo snapshot me > /data/notes.txt'
Create a VolumeSnapshot, naming the claim it's a snapshot of, and
the VolumeSnapshotClass from earlier:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: demo-snapshot
spec:
volumeSnapshotClassName: localdir-csi-snapshot
source:
persistentVolumeClaimName: demo-claim
Save that as demo-volumesnapshot.yaml, outside deploy/, the same
place Chapter 8 kept demo-pvc.yaml — a workload object, not part of
this driver's own deployment.
kubectl apply -f demo-volumesnapshot.yaml
kubectl get volumesnapshot demo-snapshot
NAME READYTOUSE SOURCEPVC RESTORESIZE SNAPSHOTCLASS SNAPSHOTCONTENT AGE
demo-snapshot false demo-claim localdir-csi-snapshot 2s
READYTOUSE false — a moment worth watching, not skipping past.
Check what's actually happening:
kubectl logs -n kube-system -l app.kubernetes.io/name=snapshot-controller --tail=10
I0821 10:22:01.334221 1 snapshot_controller.go:255] createSnapshotContent: Creating content for snapshot demo-snapshot through the plugin ...
I0821 10:22:01.335590 1 snapshot_controller.go:271] volumeSnapshotContent "snapcontent-..." created
That's snapshot-controller — cluster-level, watching VolumeSnapshot
directly — creating the matching VolumeSnapshotContent object.
Nothing has called CreateSnapshot yet; that's the next step, and it
belongs to a different component entirely:
kubectl logs -l app=localdir-csi-controller -c csi-snapshotter --tail=10
I0821 10:22:01.410332 1 snapshotter.go:126] CreateSnapshot for content snapcontent-3a9f21e0-...
I0821 10:22:01.410451 1 controller.go:759] createSnapshotWrapper: Creating snapshot for content ...
I0821 10:22:01.421187 1 controller.go:801] createSnapshotWrapper: create snapshot ... succeeded
csi-snapshotter — per-driver, watching VolumeSnapshotContent — is
what actually called CreateSnapshot, the exact method this chapter
built, over the exact same socket external-provisioner already shares.
A moment later:
kubectl get volumesnapshot demo-snapshot
NAME READYTOUSE SOURCEPVC RESTORESIZE SNAPSHOTCLASS SNAPSHOTCONTENT AGE
demo-snapshot true demo-claim 1Gi localdir-csi-snapshot snapcontent-3a9f21e0-... 6s
READYTOUSE true — this driver's own ReadyToUse: true, set the
instant CreateSnapshot returned, now visible all the way up through
VolumeSnapshotContent to the VolumeSnapshot a person actually
looks at. Confirm the data itself, directly on the node's disk, the
same way every earlier proof in this book has:
CONTENT=$(kubectl get volumesnapshot demo-snapshot -o jsonpath='{.status.boundVolumeSnapshotContentName}')
SNAPID=$(kubectl get volumesnapshotcontent "$CONTENT" -o jsonpath='{.status.snapshotHandle}')
docker exec csi-dev-control-plane cat /var/lib/localdir-csi/data/snapshots/"$SNAPID"/notes.txt
snapshot me
The exact string a pod wrote through a real mount, now sitting inside a
snapshot directory nothing but a kubectl apply ever asked for by
name.
Tear it down:
kubectl delete volumesnapshot demo-snapshot
docker exec csi-dev-control-plane ls /var/lib/localdir-csi/data/snapshots/
Empty — DeleteSnapshot, called automatically, the same cleanup
Chapter 8 already proved for DeleteVolume when a claim gets deleted.
What you should have now
internal/driver/controller.go: realCreateSnapshotandDeleteSnapshot, both tested, both idempotent — the conflict check came from designing the metadata in from day one, but the idempotency bug itself was found the same way Chapter 9 foundCreateVolume's capacity gap: a test that passed by accident (an empty test volume hid it), caught for real once a real cluster run copied actual data- A working sidecar-metadata-file pattern (
snapshotMeta,writeSnapshotMeta,readSnapshotMeta) that answers "what was this called with, last time" for data a bare directory can't remember on its own ControllerGetCapabilitiesadvertisingCREATE_DELETE_SNAPSHOT, proving out the exact Open/Closed extension point Chapter 7 designed in — and one honest fix to a Chapter 7 test that wasn't quite as future-proof as its own implementationdeploy/controller.yaml's third container, csi-snapshotter, anddeploy/controller-rbac.yaml's secondClusterRole, both trimmed from real upstream reference manifests the same honest way Chapter 7 trimmed external-provisioner'sdeploy/volumesnapshotclass.yaml, and the first cluster-level, not-driver-specific component this book has ever had to install: snapshot-controller, shared infrastructure any CSI driver's snapshot support depends on- Two forms of proof again,
grpcurlby hand and a realVolumeSnapshotobject watched end to end — data written through a real mount, copied by a realCreateSnapshotcall nothing typed by hand ever triggered, and readable straight off the node's disk afterward
Two honest gaps remain, both already named directly above:
ListSnapshots stays an unadvertised, correctly-skipped stub, and
CreateVolume still can't restore a volume from a snapshot's contents
— volume_content_source sits in CreateVolumeRequest, unread, same
as every chapter since Chapter 7. Both stay that way for the rest of
this book — deliberate, not deferred. Chapter 11 turns to a different
kind of gap: not a missing RPC, but everything this driver is still
missing to be safely operated for real — structured error codes,
logs, and metrics that can actually be scraped off a running pod.