Chapter 11: Getting to Production
A fire drill and a real fire look identical for the first ten seconds. The alarm sounds the same. People stand up the same way. What tells them apart is everything that was already in place before the alarm went off: whether the exits were marked, whether anyone knew which stairwell to use, whether the building even had a plan. Production incidents work the same way. The gRPC call that fails at 3am looks exactly like the gRPC call that failed in a unit test at 3pm — same error, same stack, same shape. What tells them apart is everything already in place before that call failed: whether the error said anything useful, whether anyone could see it happening, whether a retry made things better or quietly made them worse.
localdir-csi works. Every RPC this book has built is tested,
green, and proven against a real cluster. That was never really in
question — TDD makes "does it work" almost boring to answer. What
this chapter asks is a different question: if this driver were
running for real, at 3am, with nobody watching, would anyone be able
to tell what went wrong? Right now, mostly not. This chapter closes
that gap in two concrete ways — structured, auditable error codes,
and logs and metrics an operator can actually use, actually scraped
off a real running pod — without changing what this driver does, only
what it's like to run.
What "production" doesn't mean here
Worth saying plainly, the way this book has named every other honest
simplification: this chapter does not turn localdir-csi into
something you'd actually run in a real cluster. It still stores
volumes as plain directories on one node, with no real capacity
enforcement, no multi-node story, and no real backend underneath it.
Volume Expansion was cut from this book's plan for exactly this
reason — ControllerExpandVolume/NodeExpandVolume would have
nothing real to exercise, since CreateVolume never enforced a real
size limit to begin with. Padding out an RPC nobody could meaningfully
test isn't "getting to production," it's decoration.
What is real, and worth a whole chapter: the operational concerns that apply to this driver exactly as much as they'd apply to a driver backed by a real SAN. Bad error codes confuse real callers regardless of what's underneath. Missing logs and metrics leave a real operator blind regardless of what's underneath. Both problems are real, and fixable, without pretending this toy driver is something it isn't.
Structured error codes: an audit, and a DRY pass
Every RPC in this driver already returns a codes.X — that's been
true since Chapter 3. What hasn't happened yet is checking that every
one of those codes actually matches what the CSI spec says callers
should expect. The spec's own spec.md documents, RPC by RPC, which
conditions map to which gRPC code — the same kind of upstream source
this book has checked before drafting since Chapter 3's "check the
interfaces first" convention, just applied retroactively this time,
against code that already shipped.
Read internal/driver/controller.go and internal/driver/node.go
against that table, RPC by RPC, and it holds up. A few gaps are real
but already-named simplifications — DeleteVolume never returns
FAILED_PRECONDITION for "volume in use," because this driver has no
real attachment tracking to know that; ControllerPublishVolume never
returns NOT_FOUND for a nonexistent node, because a single-node
driver has no node registry to check against. Both already flagged, in
Chapter 7 and Chapter 8's own text — nothing new to fix there.
Two other gaps used to live on this list too: CreateVolume not
checking capacity on retry, and NodePublishVolume not checking
access mode on retry. Both real bugs, not simplifications — and both
already closed by the time this chapter starts. NodePublishVolume
got its fix in Chapter 6, the moment its own IsMountPoint check
turned out to answer a narrower question than "is this genuinely the
same request." CreateVolume got its fix in Chapter 9, the moment a
real csi-sanity run turned the same suspicion into a real failing
test. Nothing left to fix here — but the audit does turn up something
the fixes themselves left behind.
Read those two fixes next to CreateSnapshot's own idempotency check
from Chapter 10, and the same nine lines show up in all three, changed
only in which metadata type they read and what they compare:
if existing, err := readXMeta(d.dataDir, id); err == nil {
if <conflict> {
return nil, status.Errorf(codes.AlreadyExists, ...)
}
// ...use existing, return early...
} else if !os.IsNotExist(err) {
return nil, status.Errorf(codes.Internal, "reading ... metadata: %v", err)
}
Three RPCs, three metadata types, one repeated shape: read a record, and turn "does it exist" into one of three answers — no record yet, here's the record, or something went wrong reading it. Always DRY says that repetition is overdue for collapsing, the same way Chapter 6's own test helpers collapsed a different repeated shape a few chapters back.
Add internal/driver/meta.go:
package driver
import "os"
// readMetaOrZero reads metadata via read. If a record exists, it comes
// back with found=true. If none exists yet, readMetaOrZero returns the
// zero value with found=false and no error — the one signal every
// idempotency check in this driver needs before deciding whether a
// request is the first one for this name, or a retry of one already
// recorded. Any other read error is returned as-is, for the caller to
// wrap into its own codes.Internal message.
func readMetaOrZero[T any](read func() (T, error)) (meta T, found bool, err error) {
meta, err = read()
if err == nil {
return meta, true, nil
}
if os.IsNotExist(err) {
var zero T
return zero, false, nil
}
var zero T
return zero, false, err
}
Then each of the three call sites shrinks by a line and reads a little
straighter. CreateVolume, in internal/driver/controller.go:
existing, found, err := readMetaOrZero(func() (volumeMeta, error) {
return readVolumeMeta(d.dataDir, req.GetName())
})
if err != nil {
return nil, status.Errorf(codes.Internal, "reading volume %q metadata: %v", req.GetName(), err)
}
if found {
if existing.CapacityBytes != requestedBytes {
return nil, status.Errorf(codes.AlreadyExists, "volume %q already exists with a different capacity", req.GetName())
}
return &csi.CreateVolumeResponse{
Volume: &csi.Volume{
VolumeId: req.GetName(),
CapacityBytes: existing.CapacityBytes,
},
}, nil
}
CreateSnapshot, same file:
existingSnap, foundSnap, err := readMetaOrZero(func() (snapshotMeta, error) {
return readSnapshotMeta(d.dataDir, req.GetName())
})
if err != nil {
return nil, status.Errorf(codes.Internal, "reading snapshot %q metadata: %v", req.GetName(), err)
}
if foundSnap {
if existingSnap.SourceVolumeID != req.GetSourceVolumeId() {
return nil, status.Errorf(codes.AlreadyExists, "snapshot %q already exists for a different source volume", req.GetName())
}
return &csi.CreateSnapshotResponse{
Snapshot: &csi.Snapshot{
SnapshotId: req.GetName(),
SourceVolumeId: existingSnap.SourceVolumeID,
SizeBytes: existingSnap.SizeBytes,
CreationTime: timestamppb.New(existingSnap.CreationTime),
ReadyToUse: true,
},
}, nil
}
NodePublishVolume, in internal/driver/node.go — the variable is
renamed metaErr here only because err already names the result of
the IsMountPoint call one line up:
if mounted {
existing, found, metaErr := readMetaOrZero(func() (nodePublishMeta, error) {
return readNodePublishMeta(d.dataDir, req.GetVolumeId())
})
if metaErr != nil {
return nil, status.Errorf(codes.Internal, "reading volume %q publish metadata: %v", req.GetVolumeId(), metaErr)
}
if found && (existing.TargetPath != req.GetTargetPath() || existing.Readonly != req.GetReadonly()) {
return nil, status.Errorf(codes.AlreadyExists, "volume %q is already published with different parameters", req.GetVolumeId())
}
return &csi.NodePublishVolumeResponse{}, nil
}
This refactor doesn't earn a new red test — nothing about what these three RPCs do changes, only how they ask the same question of their own metadata. The 46 tests already in the suite, spanning every RPC this book has built so far, are the safety net:
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.013s
Green, unchanged. Three call sites, one shared shape, one file
(meta.go) that didn't exist an hour ago — Always DRY, applied to
code this book already trusted, not just code it was about to write.
Capability enforcement: a gap the audit above missed
The error-code audit above checked what each RPC returns. It didn't
check what each RPC accepts — and that gap is real, not
hypothetical. ValidateVolumeCapabilities (Chapter 9) already knows
exactly what this driver supports: a mount volume, never a raw block
volume, requesting SINGLE_NODE_WRITER access. But
ValidateVolumeCapabilities is an advisory RPC — nothing forces a
caller to invoke it before CreateVolume, ControllerPublishVolume,
or NodePublishVolume, and none of those three actually checks the
capability it was handed against that same rule. Right now, a
CreateVolume call naming a block-volume capability succeeds anyway;
a NodePublishVolume call naming MULTI_NODE_MULTI_WRITER still gets
a real bind mount. ValidateVolumeCapabilities would have correctly
said no to both — it just never gets asked.
Always DRY says the check belongs in one place, not copy-pasted into
four RPCs with four chances to drift out of sync with each other. Add
internal/driver/capabilities.go:
package driver
import "github.com/container-storage-interface/spec/lib/go/csi"
// supportsVolumeCapability reports whether c is a capability this
// driver can actually honor: a mount volume — never a raw block
// volume — requesting SINGLE_NODE_WRITER access, the only combination
// Chapter 9's csi-sanity run ever exercised successfully. Every RPC
// that receives a *csi.VolumeCapability calls this before doing any
// real work with it, not just ValidateVolumeCapabilities, whose whole
// job is answering this exact question.
func supportsVolumeCapability(c *csi.VolumeCapability) bool {
return c.GetMount() != nil &&
c.GetAccessMode().GetMode() == csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER
}
With a test, internal/driver/capabilities_test.go:
package driver
import (
"testing"
"github.com/container-storage-interface/spec/lib/go/csi"
)
func TestSupportsVolumeCapability_AcceptsSupported(t *testing.T) {
c := &csi.VolumeCapability{
AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER},
}
if !supportsVolumeCapability(c) {
t.Error("supportsVolumeCapability() = false, want true for mount + SINGLE_NODE_WRITER")
}
}
func TestSupportsVolumeCapability_RejectsUnsupported(t *testing.T) {
cases := map[string]*csi.VolumeCapability{
"block volume": {
AccessType: &csi.VolumeCapability_Block{Block: &csi.VolumeCapability_BlockVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER},
},
"multi-node access mode": {
AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER},
},
}
for name, c := range cases {
if supportsVolumeCapability(c) {
t.Errorf("supportsVolumeCapability(%s) = true, want false", name)
}
}
}
ValidateVolumeCapabilities's own capability loop (Chapter 9) already
checked exactly this, just inline and in two separate if statements.
Collapse it onto the new helper:
for _, c := range req.GetVolumeCapabilities() {
if !supportsVolumeCapability(c) {
return &csi.ValidateVolumeCapabilitiesResponse{
Message: "capability not supported: only a mount volume with SINGLE_NODE_WRITER access is supported",
}, nil
}
}
Then the three RPCs that were missing the check entirely. In
CreateVolume (internal/driver/controller.go), right after the
existing "capabilities are required" presence check:
for _, c := range req.GetVolumeCapabilities() {
if !supportsVolumeCapability(c) {
return nil, status.Errorf(codes.InvalidArgument, "unsupported volume_capability: only a mount volume with SINGLE_NODE_WRITER access is supported")
}
}
In ControllerPublishVolume, same file, right after its own
"volume_capability is required" check:
if !supportsVolumeCapability(req.GetVolumeCapability()) {
return nil, status.Error(codes.InvalidArgument, "unsupported volume_capability: only a mount volume with SINGLE_NODE_WRITER access is supported")
}
And in NodePublishVolume (internal/driver/node.go), right after its
own "volume_capability is required" check:
if !supportsVolumeCapability(req.GetVolumeCapability()) {
return nil, status.Error(codes.InvalidArgument, "unsupported volume_capability: only a mount volume with SINGLE_NODE_WRITER access is supported")
}
Two tests prove the wiring, not just the helper. In
internal/driver/controller_test.go:
func TestCreateVolume_RejectsUnsupportedCapability(t *testing.T) {
d := newTestDriverInTempDir(t)
req := &csi.CreateVolumeRequest{
Name: "vol-1",
VolumeCapabilities: []*csi.VolumeCapability{{
AccessType: &csi.VolumeCapability_Block{Block: &csi.VolumeCapability_BlockVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER},
}},
}
_, err := d.CreateVolume(t.Context(), req)
requireStatusCode(t, err, codes.InvalidArgument, req)
}
And in internal/driver/node_test.go:
func TestNodePublishVolume_RejectsUnsupportedCapability(t *testing.T) {
d := newTestDriver(t, t.TempDir(), &fakeMounter{})
req := &csi.NodePublishVolumeRequest{
VolumeId: "vol-1",
TargetPath: filepath.Join(t.TempDir(), "target"),
VolumeCapability: &csi.VolumeCapability{
AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}},
AccessMode: &csi.VolumeCapability_AccessMode{Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER},
},
}
_, err := d.NodePublishVolume(t.Context(), req)
requireStatusCode(t, err, codes.InvalidArgument, req)
}
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.014s
Green — four RPCs now agree on what this driver supports, checked in exactly one place, instead of one RPC knowing the rule and three others silently ignoring it.
Path safety: an audit of everything this driver joins onto a path
One more gap the error-code audit didn't cover, because it's not about
codes at all: every RPC that touches the filesystem builds a path by
joining d.dataDir with a caller-supplied string — a volume ID, a
snapshot ID — and CSI identifiers are opaque by spec. Nothing stops a
caller from naming a volume ../../etc or snapshots (this driver's
own reserved subdirectory). The external-provisioner-generated names
this book's own proofs have used so far happen to look safe; the spec
never promised that, and every grpcurl demonstration since Chapter 7
has reached this driver's socket directly, no sidecar validation in
front of it at all.
One helper closes every one of those joins at once, in
internal/driver/paths.go:
package driver
import (
"fmt"
"path/filepath"
"strings"
)
// reservedNames are subdirectories this driver's own bookkeeping
// already owns under dataDir. A volume or snapshot ID that collided
// with one of these would silently corrupt the driver's own metadata
// instead of failing loudly.
var reservedNames = map[string]bool{
"snapshots": true,
".csi-meta": true,
}
// safeChildPath joins name onto root, the same way every RPC in this
// driver already has with filepath.Join — but refuses to do it at all
// if name could escape root or collide with a reserved directory.
// CSI volume/snapshot IDs are caller-supplied and opaque; this driver
// has no sidecar in front of it once a caller reaches its socket
// directly, so it has to be the one place that says no.
func safeChildPath(root, name string) (string, error) {
if name == "" {
return "", fmt.Errorf("name must not be empty")
}
if reservedNames[name] {
return "", fmt.Errorf("name %q collides with a reserved internal directory", name)
}
if name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("name %q is not a valid path component", name)
}
child := filepath.Join(root, name)
cleanRoot := filepath.Clean(root)
if child != cleanRoot && !strings.HasPrefix(child, cleanRoot+string(filepath.Separator)) {
return "", fmt.Errorf("name %q escapes the data root", name)
}
return child, nil
}
The separator-and-..-literal checks catch the common case in plain
English; the final prefix check is the one that actually matters,
because filepath.Join already runs filepath.Clean internally —
anything cleverer than a literal .. component that still resolves
outside root after cleaning gets caught there, not by the earlier
checks. Four cases worth a real test each, in
internal/driver/paths_test.go:
package driver
import (
"path/filepath"
"testing"
)
func TestSafeChildPath_AcceptsValidName(t *testing.T) {
root := t.TempDir()
got, err := safeChildPath(root, "vol-1")
if err != nil {
t.Fatalf("safeChildPath() error = %v, want nil", err)
}
if want := filepath.Join(root, "vol-1"); got != want {
t.Errorf("safeChildPath() = %q, want %q", got, want)
}
}
func TestSafeChildPath_RejectsTraversal(t *testing.T) {
if _, err := safeChildPath(t.TempDir(), "../escape"); err == nil {
t.Error("safeChildPath() = nil error, want one for a traversal attempt")
}
}
func TestSafeChildPath_RejectsSeparators(t *testing.T) {
if _, err := safeChildPath(t.TempDir(), "sub/dir"); err == nil {
t.Error("safeChildPath() = nil error, want one for an embedded separator")
}
}
func TestSafeChildPath_RejectsReservedNames(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"snapshots", ".csi-meta"} {
if _, err := safeChildPath(root, name); err == nil {
t.Errorf("safeChildPath(%q) = nil error, want one for a reserved name", name)
}
}
}
Wiring it in means every existing filepath.Join(d.dataDir, ...) (or
filepath.Join(dataDir, ...) inside a metadata-path helper) becomes a
checked call instead, returning codes.InvalidArgument on rejection —
an invalid ID is a caller mistake, not a server failure. The call sites,
each a one-line change plus an error check:
| File | Function | Old | New |
|---|---|---|---|
controller.go | CreateVolume | path := filepath.Join(d.dataDir, req.GetName()) | path, err := safeChildPath(d.dataDir, req.GetName()) (+ check) |
controller.go | DeleteVolume | path := filepath.Join(d.dataDir, req.GetVolumeId()) | path, err := safeChildPath(d.dataDir, req.GetVolumeId()) (+ check) |
controller.go | ControllerPublishVolume | path := filepath.Join(d.dataDir, req.GetVolumeId()) | same pattern |
controller.go | CreateSnapshot | sourcePath := filepath.Join(d.dataDir, req.GetSourceVolumeId()) | same pattern, on req.GetSourceVolumeId() |
node.go | NodePublishVolume | source := filepath.Join(d.dataDir, req.GetVolumeId()) | same pattern |
meta.go/helpers | volumeMetaPath, nodePublishMetaPath, snapshotMetaPath, snapshotDataPath | filepath.Join(dataDir, ..., id+".json") | build the checked child path first, then join the fixed metadata suffix onto that |
Each replacement follows the same shape:
path, err := safeChildPath(d.dataDir, req.GetVolumeId())
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid volume_id: %v", err)
}
— path used exactly where the old filepath.Join result was, nothing
else in any of these methods changes. Run the full suite after wiring
every site in the table above:
go test ./internal/driver/...
ok github.com/yourname/localdir-csi/internal/driver 0.015s
Green — every id this driver ever joins onto a real path now goes through one function that can say no, and that function has its own tests proving it actually does.
Structured logging: one interceptor, every RPC
The second problem named above: missing logs leave a real operator
blind. Right now, nothing in internal/driver logs anything. A
CreateVolume call that fails shows up as a gRPC error the caller
sees — and nowhere else. On a real cluster, the caller is usually
external-provisioner, several hops away from whoever is debugging.
The tempting fix is to add a klog.InfoS call at the top and bottom
of every RPC method. Always DRY says no: that's the same four lines,
copy-pasted into eleven methods, all of them needing to change in
lockstep the day the log format changes. gRPC has a purpose-built
answer to exactly this shape of problem — a unary interceptor: a
function that wraps every RPC call, given the request, the handler,
and a chance to run code before and after it runs. Wire one in once,
and every RPC — this one and every one added later — gets logging for
free, without a single klog call inside any RPC method itself. This
is Open/Closed in its purest form: adding logging to RPC number twelve
requires touching zero existing files.
k8s.io/klog/v2 is the structured logging package the rest of
Kubernetes uses — InfoS and ErrorS take a message and alternating
key/value pairs, rather than a Printf-style format string. Add it
the same way every other real dependency in this book got added,
back in Chapter 2:
go get k8s.io/klog/v2
klog has no public Output variable to redirect for a test —
capturing its output means installing a real logr.Logger via
SetLogger, the same way klog's own real test suite does it.
k8s.io/klog/v2/textlogger supplies a ready-made one: a logr.Logger
that writes plain text to whatever io.Writer it's given, and it's
already part of the k8s.io/klog/v2 module — no separate dependency
to add for it.
Start with the test, in internal/driver/logging_test.go:
package driver
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"google.golang.org/grpc"
"k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger"
)
func TestLoggingInterceptor_LogsSuccess(t *testing.T) {
var buf bytes.Buffer
klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Output(&buf))))
t.Cleanup(klog.ClearLogger)
handler := func(ctx context.Context, req any) (any, error) {
return "ok", nil
}
info := &grpc.UnaryServerInfo{FullMethod: "/csi.v1.Controller/CreateVolume"}
_, err := LoggingInterceptor(t.Context(), nil, info, handler)
if err != nil {
t.Fatalf("LoggingInterceptor() returned an error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "RPC succeeded") {
t.Errorf("log output = %q, want it to contain %q", got, "RPC succeeded")
}
if !strings.Contains(got, "/csi.v1.Controller/CreateVolume") {
t.Errorf("log output = %q, want it to contain the method name", got)
}
}
func TestLoggingInterceptor_LogsFailure(t *testing.T) {
var buf bytes.Buffer
klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Output(&buf))))
t.Cleanup(klog.ClearLogger)
wantErr := errors.New("volume not found")
handler := func(ctx context.Context, req any) (any, error) {
return nil, wantErr
}
info := &grpc.UnaryServerInfo{FullMethod: "/csi.v1.Controller/DeleteVolume"}
_, err := LoggingInterceptor(t.Context(), nil, info, handler)
if err != wantErr {
t.Fatalf("LoggingInterceptor() error = %v, want %v", err, wantErr)
}
got := buf.String()
if !strings.Contains(got, "RPC failed") {
t.Errorf("log output = %q, want it to contain %q", got, "RPC failed")
}
if !strings.Contains(got, "volume not found") {
t.Errorf("log output = %q, want it to contain the error", got)
}
}
Each test installs a textlogger pointed at a bytes.Buffer instead
of the default stderr, and clears it in t.Cleanup via
klog.ClearLogger, so no other test in the package inherits a logger
whose buffer has since gone out of scope. klog.InfoS/ErrorS route
through whatever's installed with SetLogger — install a logger, and
every top-level klog.InfoS/ErrorS call anywhere in the program
starts going through it instead of the classic stderr path.
Neither LoggingInterceptor nor grpc.UnaryServerInfo/UnaryHandler
exist yet:
go test ./internal/driver/... -run TestLoggingInterceptor
./logging_test.go:25:12: undefined: LoggingInterceptor
./logging_test.go:50:12: undefined: LoggingInterceptor
FAIL github.com/yourname/localdir-csi/internal/driver [build failed]
grpc.UnaryServerInfo, grpc.UnaryHandler, and
grpc.UnaryServerInterceptor are real types this driver's existing
google.golang.org/grpc dependency already provides — no new module
to add for them.
With those in place, internal/driver/logging.go:
package driver
import (
"context"
"time"
"google.golang.org/grpc"
"k8s.io/klog/v2"
)
// LoggingInterceptor logs every unary RPC call once, on the way out:
// a structured success line with the method name and how long it took,
// or a structured error line with the method name and the error. Wire
// it in once, as a grpc.UnaryInterceptor, and every RPC — this one and
// every one added later — gets consistent logging for free, without a
// single klog call inside any RPC method itself.
func LoggingInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start)
if err != nil {
klog.ErrorS(err, "RPC failed", "method", info.FullMethod, "duration", duration)
return resp, err
}
klog.InfoS("RPC succeeded", "method", info.FullMethod, "duration", duration)
return resp, nil
}
go test ./internal/driver/... -run TestLoggingInterceptor -v
=== RUN TestLoggingInterceptor_LogsSuccess
--- PASS: TestLoggingInterceptor_LogsSuccess (0.00s)
=== RUN TestLoggingInterceptor_LogsFailure
--- PASS: TestLoggingInterceptor_LogsFailure (0.00s)
PASS
ok github.com/yourname/localdir-csi/internal/driver 0.004s
Green. LoggingInterceptor is a plain function — nothing about it
requires a running server, which is exactly why it could be unit
tested by calling it directly with a stub handler, the same way every
RPC method in this book has been tested since Chapter 3.
It's worth being precise about what actually happens at request time,
because LoggingInterceptor's signature — (ctx, req, info, handler)
— looks like an ordinary function until you notice that one of its
arguments is itself a function it hasn't called yet. When a
CreateVolume request arrives at a server wired with
grpc.UnaryInterceptor(driver.LoggingInterceptor), gRPC's own
machinery decodes the request off the wire into a
*csi.CreateVolumeRequest, and then — instead of calling the driver's
CreateVolume method directly — packages that call up as a closure (a
grpc.UnaryHandler value, func(ctx, req) (any, error)) and calls
LoggingInterceptor(ctx, req, info, thatClosure). The interceptor is
handed "the rest of this request's life" as a value; nothing runs
until the interceptor's own body decides to run it.
From there, LoggingInterceptor's four lines run in an order worth
tracing explicitly:
start := time.Now()runs first — genuinely before the RPC, code the interceptor controls andCreateVolumenever sees.handler(ctx, req)— the interceptor calls the closure it was handed. This is the momentCreateVolumeactually runs. Everything inside it is invisible toLoggingInterceptor, which only sees what goes in and what eventually comes back out.- Control returns to
LoggingInterceptor, now holdingresp,err, andduration := time.Since(start)— all three exist only because the interceptor waited forhandlerto return before computing anything with them. klog.InfoS/ErrorSruns — genuinely after the RPC — andLoggingInterceptorreturns(resp, err)unchanged, back up to gRPC, which encodes it onto the wire exactly as if no interceptor existed at all.
That's the whole mechanism: an interceptor is a function willing to
call another function partway through its own body, with license to
run code on either side of that call. CreateVolume never sees
LoggingInterceptor and never needs to — Chapter 6's Dependency
Inversion note (depend on abstractions, not concretions) shows up here
in a different shape: CreateVolume doesn't depend on an abstraction
for logging, it depends on nothing at all, because logging was never
its job to know about in the first place.
Wiring it in is one more line in cmd/localdir-csi/main.go, alongside
the existing service registrations:
server := grpc.NewServer(grpc.UnaryInterceptor(driver.LoggingInterceptor))
csi.RegisterIdentityServer(server, d)
csi.RegisterControllerServer(server, d)
csi.RegisterNodeServer(server, d)
reflection.Register(server)
Every RPC this driver has, or will ever have, now logs a structured
success or failure line — without one line of logging code inside
CreateVolume, NodePublishVolume, or any of the other nine methods
already written.
Metrics: the same pattern, twice
Missing logs is half the observability gap named earlier — the other
half is missing metrics. Logs answer "what happened on this one
call"; metrics answer "how is this driver doing, in aggregate, right
now" — how many CreateVolume calls failed in the last five minutes,
what NodePublishVolume's p99 latency looks like. A real operator
needs both, and neither one substitutes for the other.
The DRY argument from the logging section applies again, unchanged:
instrumenting every RPC method by hand means eleven near-identical
blocks of counter and histogram code, all needing to move together.
MetricsInterceptor is a second, independent interceptor, built the
same way as LoggingInterceptor — which is worth pausing on, because
it's a small proof of Single Responsibility working as advertised.
Logging and metrics are two different concerns; the interceptor
pattern lets them live in two different files, each one just as
unit-testable in isolation as the other, neither one aware the other
exists.
github.com/prometheus/client_golang/prometheus is the real metrics
library most of the Kubernetes ecosystem uses. Two shapes matter here.
A CounterVec is a family of monotonically-increasing counts, one per
distinct combination of label values —
rpc_requests_total{method="...",code="OK"} is a different counter
from rpc_requests_total{method="...",code="NotFound"}, even though
both live under the same CounterVec. A HistogramVec is the same
idea applied to latency: instead of one number, each label combination
gets a set of cumulative bucket counts (Buckets — how many
observations fell at or below each threshold) plus a running sum and
count, which is what lets a real Prometheus server compute
p50/p95/p99 later without this driver ever calculating a percentile
itself. Both are built from an Opts struct
(CounterOpts/HistogramOpts) whose Namespace/Subsystem/Name
fields get joined with underscores into the final metric name — this
driver's Subsystem: "localdir_csi", Name: "rpc_requests_total"
becomes localdir_csi_rpc_requests_total on the wire.
"On the wire" is worth making concrete, because this chapter is about
to build it for real, against a real running pod: once a driver
MustRegisters its collectors and serves promhttp.Handler() on an
HTTP endpoint (a real but separate piece of plumbing — this driver's
Unix-socket gRPC server has no HTTP port of its own), a real
Prometheus server scraping it sees plain text shaped like this:
# HELP localdir_csi_rpc_requests_total Total number of RPCs handled, by method and result code.
# TYPE localdir_csi_rpc_requests_total counter
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Controller/CreateVolume"} 12
localdir_csi_rpc_requests_total{code="NotFound",method="/csi.v1.Controller/DeleteVolume"} 1
# HELP localdir_csi_rpc_duration_seconds RPC latency in seconds, by method.
# TYPE localdir_csi_rpc_duration_seconds histogram
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.005"} 0
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.01"} 3
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.025"} 9
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="+Inf"} 12
localdir_csi_rpc_duration_seconds_sum{method="/csi.v1.Controller/CreateVolume"} 0.183
localdir_csi_rpc_duration_seconds_count{method="/csi.v1.Controller/CreateVolume"} 12
Every le ("less than or equal") bucket is cumulative — the +Inf
bucket always equals the total count — which is what lets Prometheus
interpolate a percentile across buckets it never saw an individual
observation land in directly. None of this book's code produces that
text; real client_golang does, via its expfmt encoder, the moment
something calls promhttp.Handler().
Add the real library the same way, one go get for the package and
its test-only testutil helper:
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/testutil
go get github.com/prometheus/client_model/go
A metric's value can only be read back out two ways: scrape
/metrics over HTTP for real, or — inside a test, without a server —
call github.com/prometheus/client_golang/prometheus/testutil.ToFloat64,
a genuine public export built for exactly this. It takes any
prometheus.Collector that reports exactly one number and returns
that number; CounterVec.WithLabelValues(...) returns a Counter,
and the real Counter interface already embeds Collector, so
handing one straight to ToFloat64 needs no extra plumbing.
Histograms are a different story — ToFloat64 explicitly panics on
anything that isn't a Gauge, Counter, or Untyped, because a histogram
doesn't reduce to one number. The real, idiomatic way to check what a
histogram recorded — the same pattern client_golang's own test suite
uses — is to Write it into a dto.Metric (the same protobuf message
a real scrape produces) and read GetHistogram().GetSampleCount() off
that. One wrinkle worth knowing before it surprises a reader:
HistogramVec.WithLabelValues(...) returns the real API's narrow
Observer interface (just Observe(float64)), not the fuller
Histogram interface that has Write on it — reaching Write needs
an explicit type assertion, s.(prometheus.Histogram), the same way
real code calling this must.
The test, in internal/driver/metrics_test.go:
package driver
import (
"context"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestMetricsInterceptor_RecordsSuccess(t *testing.T) {
handler := func(ctx context.Context, req any) (any, error) { return "ok", nil }
info := &grpc.UnaryServerInfo{FullMethod: "/csi.v1.Controller/CreateVolume"}
before := testutil.ToFloat64(rpcRequestsTotal.WithLabelValues(info.FullMethod, "OK"))
if _, err := MetricsInterceptor(t.Context(), nil, info, handler); err != nil {
t.Fatalf("MetricsInterceptor() returned an error: %v", err)
}
after := testutil.ToFloat64(rpcRequestsTotal.WithLabelValues(info.FullMethod, "OK"))
if after != before+1 {
t.Errorf("rpc_requests_total{method=%q,code=OK} = %v, want %v", info.FullMethod, after, before+1)
}
var m dto.Metric
if err := rpcDurationSeconds.WithLabelValues(info.FullMethod).(prometheus.Histogram).Write(&m); err != nil {
t.Fatalf("Write() failed: %v", err)
}
if m.GetHistogram().GetSampleCount() == 0 {
t.Errorf("rpc_duration_seconds{method=%q} recorded no observations", info.FullMethod)
}
}
func TestMetricsInterceptor_RecordsFailure(t *testing.T) {
handler := func(ctx context.Context, req any) (any, error) {
return nil, status.Error(codes.NotFound, "volume not found")
}
info := &grpc.UnaryServerInfo{FullMethod: "/csi.v1.Controller/DeleteVolume"}
before := testutil.ToFloat64(rpcRequestsTotal.WithLabelValues(info.FullMethod, "NotFound"))
if _, err := MetricsInterceptor(t.Context(), nil, info, handler); err == nil {
t.Fatal("MetricsInterceptor() returned no error, want NotFound")
}
after := testutil.ToFloat64(rpcRequestsTotal.WithLabelValues(info.FullMethod, "NotFound"))
if after != before+1 {
t.Errorf("rpc_requests_total{method=%q,code=NotFound} = %v, want %v", info.FullMethod, after, before+1)
}
}
Both tests read before/after deltas on the package-level counters
rather than asserting an absolute value — rpcRequestsTotal is
shared across every test in the package, so any test asserting an
absolute count would be quietly coupled to every other test's call
order. The histogram check only appears in the success test, and only
checks that something was recorded, not an absolute count, for the
same reason.
go test ./internal/driver/... -run TestMetricsInterceptor
# github.com/yourname/localdir-csi/internal/driver [github.com/yourname/localdir-csi/internal/driver.test]
internal/driver/metrics_test.go:7:2: "github.com/prometheus/client_golang/prometheus" imported and not used
internal/driver/metrics_test.go:19:31: undefined: rpcRequestsTotal
internal/driver/metrics_test.go:21:15: undefined: MetricsInterceptor
internal/driver/metrics_test.go:25:30: undefined: rpcRequestsTotal
internal/driver/metrics_test.go:31:12: undefined: rpcDurationSeconds
internal/driver/metrics_test.go:45:31: undefined: rpcRequestsTotal
internal/driver/metrics_test.go:47:15: undefined: MetricsInterceptor
internal/driver/metrics_test.go:51:30: undefined: rpcRequestsTotal
FAIL github.com/yourname/localdir-csi/internal/driver [build failed]
That first line is real, and a little counterintuitive: prometheus
genuinely is used, at the .(prometheus.Histogram) assertion further
down — but with rpcDurationSeconds undefined, the compiler's error
recovery gives up on that whole statement before it reaches the
assertion, so it never marks the import as referenced. It's a real Go
compiler quirk under cascading errors, not a mistake in the test.
internal/driver/metrics.go:
package driver
import (
"context"
"time"
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
var (
rpcRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Subsystem: "localdir_csi",
Name: "rpc_requests_total",
Help: "Total number of RPCs handled, by method and result code.",
},
[]string{"method", "code"},
)
rpcDurationSeconds = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Subsystem: "localdir_csi",
Name: "rpc_duration_seconds",
Help: "RPC latency in seconds, by method.",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"method"},
)
)
// MetricsInterceptor records one counter increment and one duration
// observation per RPC call, labeled by method and (for the counter) the
// result code. Wire it in alongside LoggingInterceptor — the two are
// independent, so either can be added, removed, or reused on its own.
func MetricsInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start).Seconds()
rpcRequestsTotal.WithLabelValues(info.FullMethod, status.Code(err).String()).Inc()
rpcDurationSeconds.WithLabelValues(info.FullMethod).Observe(duration)
return resp, err
}
go test ./internal/driver/... -run TestMetricsInterceptor -v
=== RUN TestMetricsInterceptor_RecordsSuccess
--- PASS: TestMetricsInterceptor_RecordsSuccess (0.00s)
=== RUN TestMetricsInterceptor_RecordsFailure
--- PASS: TestMetricsInterceptor_RecordsFailure (0.00s)
PASS
ok github.com/yourname/localdir-csi/internal/driver 0.006s
Green — two interceptors now, LoggingInterceptor and
MetricsInterceptor, each tested in complete isolation from the
other. The wiring from the logging section only has room for one:
grpc.UnaryInterceptor installs exactly one interceptor. The real
grpc package's answer for more than one is
grpc.ChainUnaryInterceptor, which takes any number of interceptors
and nests them: the first one listed ends up outermost, wrapping every
interceptor after it the same way nested function calls wrap each
other — first's code runs before second's on the way in, and after
second's on the way back out. That matters for this driver's wiring,
because it decides what a caller sees timed:
server := grpc.NewServer(grpc.ChainUnaryInterceptor(
driver.LoggingInterceptor,
driver.MetricsInterceptor,
))
csi.RegisterIdentityServer(server, d)
csi.RegisterControllerServer(server, d)
csi.RegisterNodeServer(server, d)
reflection.Register(server)
LoggingInterceptor listed first means its own time.Since(start)
includes MetricsInterceptor's work — the log line reports how long
the RPC actually took a caller to get an answer, metrics recording
included, not just the bare handler.
Serving metrics for real
Recording a metric and being able to see it are two different things.
rpcRequestsTotal and rpcDurationSeconds update in memory on every
RPC — that part is already proven by metrics_test.go. But nothing
so far reads those numbers back out except a unit test calling
testutil.ToFloat64 directly. On a real cluster, the thing that reads
them out is a Prometheus server, and it can only do that by making an
HTTP request. This driver doesn't have an HTTP server — its only
listener is the Unix-socket gRPC server kubelet talks to. Fixing that
is a few real lines, not an exercise, so this section builds it and
then goes and looks at it running on a real pod.
CounterVec/HistogramVec values only show up on a scrape once
they're registered with a prometheus.Registry — creating them with
NewCounterVec/NewHistogramVec does not register them anywhere on
its own. prometheus.MustRegister does that; it panics on a
registration error (a name collision, say), which is the right
failure mode here — a metrics wiring bug should be loud at startup,
not silently drop data forever. Add the registration call to
internal/driver/metrics.go, right after the two var declarations:
func init() {
prometheus.MustRegister(rpcRequestsTotal, rpcDurationSeconds)
}
prometheus.MustRegister with no explicit Registry argument
registers against prometheus.DefaultRegisterer — the same default
registry promhttp.Handler() reads from with no arguments of its own.
That pairing is why the wiring in main.go doesn't have to pass
anything explicit between the two: registering here and serving there
agree on "the default registry" without either line naming it.
main.go gets one more piece, alongside the existing gRPC server
startup — a second server, on a second port, running the whole time
the driver runs. Two new imports: net/http, the standard library's
own HTTP server, and
github.com/prometheus/client_golang/prometheus/promhttp, the
sub-package that turns a Registry into an http.Handler:
go func() {
http.Handle("/metrics", promhttp.Handler())
if err := http.ListenAndServe(":9090", nil); err != nil {
klog.ErrorS(err, "metrics server failed")
}
}()
That go func() matters as much as the two lines inside it.
http.ListenAndServe blocks — it doesn't return until the server
stops or fails — so without go, starting the metrics server would
mean the driver's real job, serving the CSI gRPC socket, never starts.
Running it in its own goroutine means the metrics HTTP server and the
gRPC server run side by side, on two different listeners (:9090 for
HTTP, the Unix socket for gRPC), each blocking only its own goroutine.
The deploy manifest needs to say the port exists, or nothing outside
the pod's own network namespace can reach it. Both deploy/node.yaml
and deploy/controller.yaml run this same driver binary, so both
containers get the same addition:
ports:
- containerPort: 9090
name: metrics
That's everything the code needs. The rest of this section is running
it for real: rebuild and redeploy the driver, then reach port 9090 on
a live pod the same way kubectl port-forward reaches any other pod
port, and see what a real Prometheus scrape would see.
make deploy
kubectl port-forward deployment/localdir-csi-controller 9090:9090
make deploy already waits for both the Controller and Node rollouts
to finish (Chapter 2), so by the time port-forward runs, the pod on
the other end is genuinely the freshly built binary, not whatever was
running before.
Then, in a second terminal, with a couple of volumes already created against the driver so the counters aren't sitting at zero:
curl -s localhost:9090/metrics | grep localdir_csi
# HELP localdir_csi_rpc_duration_seconds RPC latency in seconds, by method.
# TYPE localdir_csi_rpc_duration_seconds histogram
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.005"} 23
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.01"} 23
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="0.025"} 23
localdir_csi_rpc_duration_seconds_bucket{method="/csi.v1.Controller/CreateVolume",le="+Inf"} 23
localdir_csi_rpc_duration_seconds_sum{method="/csi.v1.Controller/CreateVolume"} 0.004305040999999999
localdir_csi_rpc_duration_seconds_count{method="/csi.v1.Controller/CreateVolume"} 23
# HELP localdir_csi_rpc_requests_total Total number of RPCs handled, by method and result code.
# TYPE localdir_csi_rpc_requests_total counter
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Controller/CreateSnapshot"} 34
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Controller/CreateVolume"} 23
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Controller/DeleteSnapshot"} 24
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Controller/DeleteVolume"} 36
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Identity/GetPluginInfo"} 3
localdir_csi_rpc_requests_total{code="OK",method="/csi.v1.Identity/Probe"} 2
Every counter above is real — a scrape off a real
localdir-csi-controller pod on a kind cluster, after this book's
own test traffic (the Chapter 10 snapshot proofs, a handful of
CreateVolume calls) had already run against it. CreateVolume alone
recorded 23 calls, all code="OK", and its histogram's +Inf bucket
— 23 — equals its total count, exactly the cumulative-bucket property
explained above. Read the same numbers off your own pod with the three
commands above and they'll differ in the counts, not in the shape.
What you should have now
internal/driver/meta.go'sreadMetaOrZero, collapsing a read/compare/decide shape that had been hand-written three times — inNodePublishVolume(Chapter 6),CreateVolume(Chapter 9), andCreateSnapshot(Chapter 10) — into one generic helper, all three call sites updated, all 46 pre-existing tests still green with no behavior change- Confirmation, from a real audit against the CSI spec's own
error-code table, that every other already-known simplification
(
DeleteVolume's missingFAILED_PRECONDITION,ControllerPublishVolume's missing node-not-foundNOT_FOUND) is still exactly what Chapter 7 and Chapter 8 already said it was — nothing new to fix, this time internal/driver/capabilities.go'ssupportsVolumeCapability, closing a real gap the error-code audit itself missed:CreateVolume,ControllerPublishVolume, andNodePublishVolumenow reject any capabilityValidateVolumeCapabilitieswould already have refused, instead of silently accepting itinternal/driver/paths.go'ssafeChildPath, closing a second real gap: every RPC that joins a caller-supplied volume or snapshot ID ontod.dataDirnow rejects traversal attempts, embedded separators, and collisions with this driver's own reserved directories, instead of handing an unvalidated opaque string straight toos.MkdirAlloros.RemoveAllinternal/driver/logging.goandinternal/driver/metrics.go: two independent gRPC unary interceptors, each tested by calling it directly with a stub handler, wired intomain.gowithgrpc.ChainUnaryInterceptor— no RPC method anywhere logs or instruments itselfk8s.io/klog/v2andgithub.com/prometheus/client_golang, both real dependencies added with plaingo get, the same way this book has added every other module since Chapter 2 — no fakes, noreplacedirectives, nothing this chapter's tests couldn't compile against on a fresh cloneprometheus.MustRegisterwired intointernal/driver/metrics.go, and a:9090HTTP server started alongside the gRPC server inmain.go, servingpromhttp.Handler()at/metrics— the difference between recording a metric and anyone actually being able to read it- Real confirmation, off a redeployed pod on the user's own
kindcluster viakubectl port-forwardandcurl, thatlocaldir_csi_rpc_requests_totalandlocaldir_csi_rpc_duration_secondsshow up on/metricsexactly as reasoned through above — not just a unit test reading the same counters back viatestutil.ToFloat64 - All 58 tests, gofmt,
go vet, andgo buildclean across the whole module — the same bar every chapter since Chapter 3 has held to
Two honest gaps remain. The metrics HTTP server has no TLS and no
auth — fine for a kubectl port-forward demo, not fine for a real
multi-tenant cluster, where a real deployment would either put it
behind a NetworkPolicy restricting who can reach port 9090, or front
it with something that authenticates scrapes. And nothing here wires
up a Prometheus ServiceMonitor or a Service object for automatic
discovery — this chapter proves the endpoint exists and answers
correctly; pointing a real Prometheus Operator at it automatically,
rather than a manual port-forward, is ordinary Kubernetes
plumbing, not something specific to this driver.
Chapter 12 turns to proving all of this automatically: real Go tests
against a real kind cluster, codifying the manual kubectl and
grpcurl sequences this book has run by hand since Chapter 8 —
including a regression test for the real CreateSnapshot idempotency
bug Chapter 10 hit live.