Chapter 12: Integration Testing
Every real-cluster proof in this book so far has the same shape: type
a kubectl command, read the output, decide whether it matches what
the prose predicted. Chapter 8 proved ControllerPublishVolume this
way. Chapter 10 proved CreateSnapshot's idempotency this way — and
also, on a live cluster, proved a real bug this way, the one where a
genuine retry collided with a file os.CopyFS had already written.
That bug was caught by a human happening to run the same command
twice and noticing the second response wasn't 0 OK. A human doesn't
always happen to do that. A test does it every time, on purpose,
without needing anyone to remember to check.
This chapter doesn't add anything to the driver. It takes proofs this
book has already done by hand — provisioning a volume, mounting it
into a pod, snapshotting it, retrying that snapshot — and writes them
down as real Go tests that run against a real kind cluster. The
payoff is concrete: the exact CreateSnapshot regression from Chapter
10 gets a test that would have caught it the day it happened, instead
of relying on whoever's at the keyboard to run the same call twice and
read the diff.
client-go, not kubectl
Every object this chapter touches — creating a PersistentVolumeClaim,
watching it bind, running a command inside a pod — has a real,
first-class API for it in k8s.io/client-go, the same library
Kubernetes' own controllers (and kubectl itself, underneath) are
built on. Reaching for that library instead of shelling out to
kubectl means every check in this chapter reads a real typed Go
value (pvc.Status.Phase, not a string parsed out of kubectl get ... -o jsonpath=...), and a failure a step earlier — a client that
never connected, a malformed object — surfaces as a real Go error at
the exact call that caused it, not as an opaque non-zero exit code
from a subprocess three layers removed from the test that's checking
it.
One operation in this chapter has no client-go equivalent, and is
named honestly rather than faked: checking the kind node's own disk
directly, the same docker exec csi-dev-control-plane cat ... proof
Chapters 8 and 10 ran by hand. That was never a Kubernetes operation
even when kubectl did it — kubectl has no subcommand for it either,
which is why those chapters reached for docker exec instead of
kubectl exec at exactly that step. client-go talks to the
Kubernetes API server; the API server has no concept of "the file
sitting on a kind node's host filesystem outside any container." That
one check stays os/exec-wrapped docker exec, unavoidably, for the
same reason it always was.
Where these tests live, and how they're kept separate
Real-cluster tests need a real cluster — they can't run in the same
go test ./... that Chapters 3–11 have relied on for everything else,
because that command runs on every commit, on any machine, with no
kind cluster guaranteed to exist. Go's answer to "these tests need
something extra" is a build tag. test/integration/integration_test.go
starts with one:
//go:build integration
package integration
go test ./... (no tags) never even compiles this file — the ordinary
test suite Chapters 3–11 built stays exactly as fast and as
cluster-independent as it's always been. Running these tests needs an
explicit opt-in:
go test -tags=integration ./test/integration/... -v
Add a Makefile target so nobody has to remember that flag, the same
way Chapter 9 added sanity-run for csi-sanity:
integration-test:
@kubectl config current-context 2>/dev/null | grep -qx kind-csi-dev || \
{ echo "current kubectl context is not kind-csi-dev — run 'make kind-up deploy' or 'kubectl config use-context kind-csi-dev' first" >&2; exit 1; }
go test -tags=integration ./test/integration/... -v -timeout 5m
The context check above matters for a reason that has nothing to do
with correctness of the tests themselves: requireCluster's own
context-and-driver checks (below) are deliberately built to t.Skip,
not fail, because a stray go test ./... with no cluster around should
report "nothing to test here," not a false failure. But that same
t.Skip means make integration-test — the thing a reader (or CI)
actually runs on purpose, expecting real coverage — would otherwise
print ok with every test skipped and zero real assertions run,
indistinguishable from a genuinely green suite. The kubectl config current-context check above closes exactly that gap: once
someone has explicitly asked for the integration suite to run, "there's
no cluster to run it against" is a failure of that request, not a
quiet no-op.
client-go is a new real dependency — nothing in internal/driver
needed it, so it isn't in go.mod yet. Pin all three of client-go,
apimachinery, and api to the same Kubernetes minor version — mixing
versions across these three modules is unsupported and will surface as
confusing type-mismatch errors, not a version-solver warning:
go get k8s.io/client-go@v0.34.0
go get k8s.io/apimachinery@v0.34.0
go get k8s.io/api@v0.34.0
k8s.io/api/core/v1 shows up directly in this chapter's imports
(corev1.PersistentVolumeClaim, corev1.Pod, ...) even though nothing
above named it explicitly — go mod tidy after writing the test files
below would catch this too, but it's worth adding up front so go build
doesn't stall on a missing module mid-chapter.
Setting up the cluster
Every test in this chapter needs the driver actually running on a real
kind cluster first — none of them stand up a cluster themselves,
the same way Chapters 8 and 10's grpcurl proofs never did either.
This is the same sequence those chapters walked through by hand,
run once before touching any test file. From the project root:
make kind-up
make deploy
Creating cluster "csi-dev" ...
✓ Ensuring node image (kindest/node:v1.36.1) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-csi-dev"
...
kubectl apply -f deploy/
serviceaccount/localdir-csi-controller created
clusterrole.rbac.authorization.k8s.io/localdir-csi-provisioner created
clusterrolebinding.rbac.authorization.k8s.io/localdir-csi-provisioner created
clusterrole.rbac.authorization.k8s.io/localdir-csi-snapshotter created
clusterrolebinding.rbac.authorization.k8s.io/localdir-csi-snapshotter created
deployment.apps/localdir-csi-controller created
csidriver.storage.k8s.io/localdir.csi.example.com created
daemonset.apps/localdir-csi-node created
storageclass.storage.k8s.io/localdir-csi created
error: resource mapping not found for name: "localdir-csi-snapshot" ... no matches for kind "VolumeSnapshotClass"
ensure CRDs are installed first
That last error is expected, and safe to ignore for this chapter:
deploy/volumesnapshotclass.yaml (Chapter 10) needs the
external-snapshotter CRDs installed cluster-wide first, and none of this chapter's
tests go through a VolumeSnapshot object — TestCreateSnapshot_IdempotentRetry
calls CreateSnapshot directly by grpcurl, the same as Chapter 10's
own grpcurl proof did before its VolumeSnapshot section. Everything
else applied cleanly. Confirm both pods came up:
kubectl get pods
NAME READY STATUS RESTARTS AGE
localdir-csi-controller-7f56558f88-tqxhk 3/3 Running 0 15s
localdir-csi-node-9s6bz 2/2 Running 0 15s
One more manual check worth running once, by hand, before trusting the
suite to run it automatically: confirm grpcurl itself actually works
against this pod, the same kubectl cp step Chapters 8 and 10 ran by
hand. (TestCreateSnapshot_IdempotentRetry below doesn't depend on
this having been run — it uploads its own copy of grpcurl every time,
so a pod restart between now and then can't silently break it — but
seeing a real response here first is worth the thirty seconds.)
CTRL_POD=$(kubectl get pods -l app=localdir-csi-controller -o jsonpath='{.items[0].metadata.name}')
kubectl cp ./grpcurl-linux $CTRL_POD:/tmp/grpcurl -c localdir-csi
kubectl exec $CTRL_POD -c localdir-csi -- chmod +x /tmp/grpcurl
kubectl exec $CTRL_POD -c localdir-csi -- /tmp/grpcurl -plaintext unix:///csi/csi.sock csi.v1.Identity/GetPluginInfo
{
"name": "localdir.csi.example.com",
"vendorVersion": "0.1.0"
}
A real response from a real running driver — the cluster is ready for
every test below. kind clusters survive between go test runs (they
aren't torn down by anything in this chapter), so this setup normally
only needs doing once per session; make kind-down tears it back down
when you're done for good.
Connecting: one client, shared by every test
Every test in this chapter needs the same thing first — a
*kubernetes.Clientset built from whatever kubeconfig kubectl
itself would use, so "the cluster this test talks to" is always the
same cluster kubectl would have talked to by hand. client-go's
clientcmd package resolves that the same way kubectl does:
KUBECONFIG, then ~/.kube/config. Rather than hand-write that
resolution in every test, one helper, in
test/integration/cluster_test.go, that everything else in this
chapter goes through — the same "one seam" shape Chapter 11's
interceptors already taught.
"Whatever kubeconfig kubectl would use" is convenient, but it cuts
both ways: this suite creates Pods and PVCs, and it deletes them in
cleanup. If your current context happens to point at some other
cluster — one you were debugging five minutes ago, say — "convenient"
becomes "this test just created and deleted real objects somewhere it
had no business touching." requireCluster doesn't just check that
a cluster is reachable; it checks that the reachable cluster is
this tutorial's disposable kind cluster, by name and by the
presence of the driver these tests are actually exercising, before
returning anything a test could use to mutate it:
//go:build integration
package integration
import (
"context"
"fmt"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// wantContext is the only kubeconfig context these tests will run
// against. `kind create cluster --name csi-dev` (Chapter 2) always
// names its context kind-<cluster-name>, so this matches this book's
// own setup exactly — change it if you named your cluster something
// else.
const wantContext = "kind-csi-dev"
// wantCSIDriverName is the CSIDriver object Chapter 4 registers. Its
// presence is this package's proof that the reachable cluster is
// running this tutorial's driver, not just any kind cluster that
// happens to be named the same thing.
const wantCSIDriverName = "localdir.csi.example.com"
// requireCluster builds a Clientset from whatever kubeconfig kubectl
// itself would use (KUBECONFIG, then ~/.kube/config), and refuses to
// return one at all unless that kubeconfig's current context is
// wantContext and the cluster it points at is actually running
// wantCSIDriverName. Every test in this package calls this first —
// and skips, rather than fails confusingly three calls later, or
// silently mutates the wrong cluster — if either check fails.
func requireCluster(t *testing.T) (*kubernetes.Clientset, *rest.Config) {
t.Helper()
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
rawConfig, err := loadingRules.Load()
if err != nil {
t.Skipf("no usable kubeconfig (%v) — run against a real kind cluster with the driver deployed", err)
}
if rawConfig.CurrentContext != wantContext {
t.Skipf("current kubeconfig context is %q, want %q — these tests create and delete real objects and must not run against the wrong cluster; run `kubectl config use-context %s` first",
rawConfig.CurrentContext, wantContext, wantContext)
}
restConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
loadingRules, &clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Skipf("no usable kubeconfig (%v) — run against a real kind cluster with the driver deployed", err)
}
clientset, err := kubernetes.NewForConfig(restConfig)
if err != nil {
t.Fatalf("kubernetes.NewForConfig: %v", err)
}
if _, err := clientset.Discovery().ServerVersion(); err != nil {
t.Skipf("cluster unreachable (%v) — run against a real kind cluster with the driver deployed", err)
}
if _, err := clientset.StorageV1().CSIDrivers().Get(context.Background(), wantCSIDriverName, metav1.GetOptions{}); err != nil {
t.Skipf("context %q is reachable, but CSIDriver %q isn't registered on it (%v) — deploy this tutorial's driver first with `make deploy`",
wantContext, wantCSIDriverName, err)
}
return clientset, restConfig
}
// uniqueName appends a nanosecond timestamp to base, so re-running this
// suite immediately after a previous run — or a previous run's cleanup
// only partially finishing, PVC finalizers included — never collides
// with an object the previous run created. Every test below calls this
// once per object it creates, rather than hardcoding a fixed name.
func uniqueName(base string) string {
return fmt.Sprintf("%s-%d", base, time.Now().UnixNano())
}
Both new checks fail closed with t.Skip, not t.Fatal — the same
"there's nothing wrong with your code, there's nothing here to test
yet" signal the reachability check already used. A context-name
mismatch or a missing CSIDriver isn't this suite's problem to fix;
it's a sign the reader hasn't pointed kubectl at the right place
yet, and the right response is to say so and stop, not to touch
anything.
restConfig gets returned alongside clientset because building an
authenticated connection into a running pod (needed further down, for
the grpcurl and file-write proofs) reuses the exact same credentials
clientcmd already resolved — no second round of "where's the
kubeconfig" logic anywhere else in this package.
Waiting for a status to change
Nothing in the Kubernetes API blocks until a PersistentVolumeClaim
says Bound — a Get call right after Create almost always still
says Pending. k8s.io/apimachinery's own wait package is the real
library idiom for exactly this shape of problem, the same package
Kubernetes' own controllers use to poll for a condition. Add it to
test/integration/cluster_test.go, alongside requireCluster —
they're both small, cluster-connection-adjacent helpers every other
test in this package calls. Add "context", "time", and
"k8s.io/apimachinery/pkg/util/wait" to that file's existing import
block (alongside "testing" and the three k8s.io/client-go imports
requireCluster already needed), then add the function itself:
// waitFor polls condition every 500ms until it returns true, or fails
// the test once timeout has passed. condition returning a non-nil error
// stops the poll immediately and fails the test with that error, the
// same way a real setup problem (not just "not ready yet") should.
func waitFor(t *testing.T, timeout time.Duration, what string, condition wait.ConditionWithContextFunc) {
t.Helper()
if err := wait.PollUntilContextTimeout(context.Background(), 500*time.Millisecond, timeout, true, condition); err != nil {
t.Fatalf("timed out waiting for %s: %v", what, err)
}
}
condition's real type, wait.ConditionWithContextFunc, is
func(ctx context.Context) (done bool, err error) — every call site
below hands waitFor a closure matching that shape, reading whatever
object it's waiting on fresh off the API server each time.
Running a command inside a pod
kubectl exec has a client-go equivalent too, though it's the one
piece in this chapter with real ceremony behind it: exec runs over a
separate, upgraded HTTP connection (SPDY), not an ordinary REST call,
because the pod's stdout is a live stream, not a JSON response body.
One helper hides that ceremony from every test that needs it, in
test/integration/exec_test.go:
//go:build integration
package integration
import (
"bytes"
"context"
"os"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
// execInPod runs command inside container of pod podName, in namespace
// ns, and returns its combined stdout+stderr. It fails the test on a
// transport error (couldn't even reach the pod) or a non-zero exit
// inside the container, both surfaced as a real Go error from
// remotecommand rather than a parsed shell exit code. ctx is taken as
// an explicit parameter rather than reaching for t.Context() inside
// the function — more on why right after this listing.
func execInPod(ctx context.Context, t *testing.T, restConfig *rest.Config, clientset *kubernetes.Clientset, ns, podName, container string, command []string) string {
t.Helper()
req := clientset.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(ns).
Name(podName).
SubResource("exec")
req.VersionedParams(&corev1.PodExecOptions{
Container: container,
Command: command,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
executor, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL())
if err != nil {
t.Fatalf("building exec request for pod %q: %v", podName, err)
}
var stdout, stderr bytes.Buffer
err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdout: &stdout,
Stderr: &stderr,
})
combined := stdout.String() + stderr.String()
if err != nil {
t.Fatalf("exec %v in pod %q: %v\n%s", command, podName, err, combined)
}
return combined
}
// uploadGrpcurl copies the local grpcurl-linux binary into podName's
// container at /tmp/grpcurl, over the same exec stream execInPod uses
// — just with Stdin attached instead of Stdout/Stderr captured, the
// same way `kubectl cp` itself streams a file through exec underneath.
// Any test that needs grpcurl calls this first, every run, rather than
// assuming a copy from a previous session survived — a controller pod
// restart (a new rollout, a node reboot) wipes /tmp along with it.
func uploadGrpcurl(ctx context.Context, t *testing.T, restConfig *rest.Config, clientset *kubernetes.Clientset, ns, podName, container string) {
t.Helper()
f, err := os.Open("grpcurl-linux")
if err != nil {
t.Fatalf("opening local grpcurl-linux binary (build it per Chapter 8's grpcurl setup): %v", err)
}
defer f.Close()
req := clientset.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(ns).
Name(podName).
SubResource("exec")
req.VersionedParams(&corev1.PodExecOptions{
Container: container,
Command: []string{"sh", "-c", "cat > /tmp/grpcurl && chmod +x /tmp/grpcurl"},
Stdin: true,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
executor, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL())
if err != nil {
t.Fatalf("building exec request for pod %q: %v", podName, err)
}
var stdout, stderr bytes.Buffer
if err := executor.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdin: f,
Stdout: &stdout,
Stderr: &stderr,
}); err != nil {
t.Fatalf("uploading grpcurl to pod %q: %v\n%s", podName, err, stdout.String()+stderr.String())
}
}
os.Open("grpcurl-linux") resolves relative to go test's own working
directory, which is the package directory — so this expects the binary
at test/integration/grpcurl-linux, not the project root. Copy or
symlink it there once; go test doesn't change directories between
runs, so this, unlike the pod-side copy, only needs doing once per
checkout.
scheme.ParameterCodec (from k8s.io/client-go/kubernetes/scheme) is
what turns the typed PodExecOptions struct — container name, the
command to run, which streams to attach — into the URL query
parameters the API server's exec subresource actually expects; it's
the same encoding kubectl exec relies on internally, just reached
directly instead of through a subprocess.
Two things about this listing are corrections, not stylistic choices, and both were found the same way every real bug in this book has been found: by running the thing against a real cluster and watching it misbehave.
The first: ctx is a parameter here, not t.Context() called
directly inside the function, because execInPod needs to work
correctly from inside a t.Cleanup closure too — Chapter's own
TestCreateSnapshot_IdempotentRetry below calls grpcurl (which
wraps execInPod) from both the test body and its cleanup, to
delete what it created. t.Context() is documented to be "canceled
just before Cleanup-registered functions are called" — always,
every run — so a version of execInPod that called t.Context()
internally would work fine everywhere except cleanup, where every
call would fail with operation was canceled. Confirmed live: an
earlier version of this exact helper did exactly that, and its
cleanup's DeleteSnapshot/DeleteVolume calls failed with precisely
that error the first time this chapter's snapshot test ran for real.
Passing ctx in means callers decide: t.Context() from the test
body, context.Background() from inside t.Cleanup — the same fix
TestCreateVolume_ViaPVC's own cleanup already needed, below.
The second is subtler, and was the real cause of a much stranger
symptom: an early version of this helper pointed both Stdout and
Stderr at the same bytes.Buffer. remotecommand streams stdout
and stderr back on separate channels, read by separate goroutines —
and bytes.Buffer is not safe for concurrent writes. Running
TestCreateSnapshot_IdempotentRetry for real against this book's
kind cluster reproduced this exactly: the two CreateSnapshot calls
occasionally came back with one of them holding a real, correct
response and the other holding an empty string, with no error from
either call — a plain data race, not a driver bug, not a network
flake, confirmed by re-running the same two grpcurl calls by hand
with kubectl exec (no client-go involved) and seeing identical,
correct output both times. go test -race on the fixed version — two
separate buffers, concatenated only after StreamWithContext returns,
once both goroutines are known to be done writing — ran clean across
several real, repeated runs against the cluster. The lesson generalizes
past this one helper: any time two ends of a concurrent stream get
pointed at the same non-thread-safe destination, "sometimes empty,
never errors" is exactly the shape of bug to expect.
Two object builders, shared instead of duplicated
Two tests below both need a PersistentVolumeClaim shaped exactly
like Chapter 8's demo-pvc.yaml, and one needs a Pod shaped exactly
like demo-pod.yaml. Writing that struct literal twice would be
exactly the kind of repetition Chapter 6 already taught this book to
notice and factor out — so both live once, in
test/integration/objects_test.go:
//go:build integration
package integration
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
// demoPVC builds a PersistentVolumeClaim matching Chapter 8's
// demo-pvc.yaml exactly — same storage class, access mode, and size.
func demoPVC(name string) *corev1.PersistentVolumeClaim {
storageClassName := "localdir-csi"
return &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: corev1.PersistentVolumeClaimSpec{
StorageClassName: &storageClassName,
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
}
}
// demoPod builds a Pod matching Chapter 8's demo-pod.yaml exactly —
// same image, same mount path, referencing whichever PVC name is passed
// in so TestNodePublishVolume_ViaPod and any future test can reuse it
// without hardcoding "demo-claim" a second time.
func demoPod(name, pvcName string) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "app",
Image: "busybox",
Command: []string{"sleep", "3600"},
VolumeMounts: []corev1.VolumeMount{{
Name: "data",
MountPath: "/data",
}},
}},
Volumes: []corev1.Volume{{
Name: "data",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: pvcName,
},
},
}},
},
}
}
Provisioning: CreateVolume, triggered by a PersistentVolumeClaim
Chapter 8's proof: create the claim, watch it go Pending → Bound,
confirm a PersistentVolume now exists. As a test, in
test/integration/provisioning_test.go:
//go:build integration
package integration
import (
"context"
"fmt"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestCreateVolume_ViaPVC(t *testing.T) {
clientset, _ := requireCluster(t)
ctx := t.Context()
pvcs := clientset.CoreV1().PersistentVolumeClaims("default")
claimName := uniqueName("it-demo-claim")
if _, err := pvcs.Create(ctx, demoPVC(claimName), metav1.CreateOptions{}); err != nil {
t.Fatalf("creating PVC: %v", err)
}
t.Cleanup(func() {
bg := context.Background()
if err := pvcs.Delete(bg, claimName, metav1.DeleteOptions{}); err != nil {
t.Errorf("deleting PVC %q: %v", claimName, err)
return
}
waitFor(t, 30*time.Second, fmt.Sprintf("PVC %s to be gone", claimName), func(ctx context.Context) (bool, error) {
_, err := pvcs.Get(ctx, claimName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return true, nil
}
return false, err
})
})
var bound *corev1.PersistentVolumeClaim
waitFor(t, 30*time.Second, fmt.Sprintf("PVC %s to bind", claimName), func(ctx context.Context) (bool, error) {
pvc, err := pvcs.Get(ctx, claimName, metav1.GetOptions{})
if err != nil {
return false, err
}
if pvc.Status.Phase == corev1.ClaimBound {
bound = pvc
return true, nil
}
return false, nil
})
if bound.Spec.VolumeName == "" {
t.Fatal("PVC bound, but Spec.VolumeName is empty")
}
pv, err := clientset.CoreV1().PersistentVolumes().Get(ctx, bound.Spec.VolumeName, metav1.GetOptions{})
if err != nil {
t.Fatalf("getting PersistentVolume %q: %v", bound.Spec.VolumeName, err)
}
if pv.Status.Phase != corev1.VolumeBound {
t.Errorf("PersistentVolume %q status = %q, want %q", pv.Name, pv.Status.Phase, corev1.VolumeBound)
}
}
t.Cleanup runs even if an assertion above it fails — the same
reason Chapters 6–10's own newTestDriverInTempDir helper uses it —
so a failed run doesn't leave the claim (and the real directory
CreateVolume made for it) behind for the next run to trip over.
uniqueName means a leftover claim from an earlier failed run can
never collide with this run's name either way, but the cleanup still
checks the Delete call's own error with t.Errorf — a silently
swallowed error here would hide a real problem (RBAC, a finalizer stuck
forever) behind a green test — and then waitFors the claim actually
disappearing rather than firing the delete and assuming it worked;
PersistentVolumeClaim carries the pvc-protection finalizer, so
Get can still return the object, Terminating, for a moment after
Delete returns.
The cleanup deliberately uses context.Background(), not ctx, and
this one is worth being exact about, because the bug it avoids is real
and easy to reintroduce by reaching for the context already in scope:
Go's own testing package documents t.Context() as "canceled just
before Cleanup-registered functions are called" — not "eventually," not
"maybe," but always, on every run, pass or fail. A Delete call made
with ctx inside t.Cleanup is handed an already-cancelled context
before it even reaches the API server, and client-go returns
immediately with a context-cancellation error — which an unchecked
cleanup wouldn't even notice, so the delete would silently never
happen. Confirmed live, against a real cluster, while drafting this
chapter: a first version using ctx in t.Cleanup left the claim
sitting there, Bound, after a passing test run. context.Background()
in cleanup, always — the same lesson TestNodePublishVolume_ViaPod
below already applies.
Run it now, against the cluster from the setup section above:
go test -tags=integration ./test/integration/... -run TestCreateVolume_ViaPVC -v
=== RUN TestCreateVolume_ViaPVC
--- PASS: TestCreateVolume_ViaPVC (0.52s)
PASS
ok github.com/yourname/localdir-csi/test/integration 0.530s
Real 0 OK, against a real cluster — and worth checking the thing the
test itself can't check about its own cleanup:
kubectl get pvc
No resources found in default namespace.
Gone, confirming the context.Background() fix actually took —
exactly the same proof-that-cleanup-really-cleaned-up instinct every
docker exec ... ls check since Chapter 8 has used, just aimed at a
test's own teardown this time instead of the driver's.
Mounting: NodePublishVolume, triggered by a Pod
Chapter 8's other half: a pod that references the claim gets kubelet
to call NodePublishVolume with no grpcurl involved, and a file
written inside the pod is provably the same file sitting in the
node's own directory. In test/integration/mount_test.go:
//go:build integration
package integration
import (
"context"
"fmt"
"os/exec"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const kindNodeContainer = "csi-dev-control-plane"
func TestNodePublishVolume_ViaPod(t *testing.T) {
clientset, restConfig := requireCluster(t)
ctx := t.Context()
pvcs := clientset.CoreV1().PersistentVolumeClaims("default")
pods := clientset.CoreV1().Pods("default")
claimName := uniqueName("it-mount-claim")
podName := uniqueName("it-mount-pod")
if _, err := pvcs.Create(ctx, demoPVC(claimName), metav1.CreateOptions{}); err != nil {
t.Fatalf("creating PVC: %v", err)
}
if _, err := pods.Create(ctx, demoPod(podName, claimName), metav1.CreateOptions{}); err != nil {
t.Fatalf("creating Pod: %v", err)
}
t.Cleanup(func() {
bg := context.Background()
if err := pods.Delete(bg, podName, metav1.DeleteOptions{}); err != nil {
t.Errorf("deleting pod %q: %v", podName, err)
} else {
waitFor(t, 30*time.Second, fmt.Sprintf("pod %s to be gone", podName), func(ctx context.Context) (bool, error) {
_, err := pods.Get(ctx, podName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return true, nil
}
return false, err
})
}
if err := pvcs.Delete(bg, claimName, metav1.DeleteOptions{}); err != nil {
t.Errorf("deleting PVC %q: %v", claimName, err)
return
}
waitFor(t, 30*time.Second, fmt.Sprintf("PVC %s to be gone", claimName), func(ctx context.Context) (bool, error) {
_, err := pvcs.Get(ctx, claimName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return true, nil
}
return false, err
})
})
waitFor(t, 60*time.Second, fmt.Sprintf("pod %s to become Running", podName), func(ctx context.Context) (bool, error) {
pod, err := pods.Get(ctx, podName, metav1.GetOptions{})
if err != nil {
return false, err
}
return pod.Status.Phase == corev1.PodRunning, nil
})
execInPod(ctx, t, restConfig, clientset, "default", podName, "app",
[]string{"sh", "-c", "echo integration-test > /data/hello.txt"})
fromPod := execInPod(ctx, t, restConfig, clientset, "default", podName, "app",
[]string{"cat", "/data/hello.txt"})
if strings.TrimSpace(fromPod) != "integration-test" {
t.Fatalf("cat inside pod = %q, want %q", fromPod, "integration-test")
}
pvc, err := pvcs.Get(ctx, claimName, metav1.GetOptions{})
if err != nil {
t.Fatalf("getting PVC: %v", err)
}
nodePath := "/var/lib/localdir-csi/data/" + pvc.Spec.VolumeName + "/hello.txt"
out, err := exec.Command("docker", "exec", kindNodeContainer, "cat", nodePath).Output()
if err != nil {
t.Fatalf("docker exec cat %s: %v", nodePath, err)
}
if fromNode := strings.TrimSpace(string(out)); fromNode != "integration-test" {
t.Errorf("cat on node disk = %q, want %q", fromNode, "integration-test")
}
}
Same proof-from-both-sides technique every hand-run chapter since
Chapter 6 has used, just run by go test instead of a person: the pod
sees the file it wrote, and the node's own disk — reached through a
completely separate path, docker exec into the kind container
rather than a pod exec stream — sees the exact same content. If the
driver only pretended to bind-mount (say, it silently wrote into a
scratch directory pods never actually see), this test would still pass
its first assertion and fail its second — which is exactly why the
second one exists.
strings.TrimSpace on both sides of that comparison is not decoration
— cat's output over a real exec stream carries the trailing newline
echo wrote, same as cat always would from a real terminal. Compare
fromPod (or fromNode) without trimming it first and this test fails
on a difference that was never actually a difference in the data, only
in whitespace neither side cares about — a real, easy mistake, not a
hypothetical one.
Run it now:
go test -tags=integration ./test/integration/... -run TestNodePublishVolume_ViaPod -v
=== RUN TestNodePublishVolume_ViaPod
--- PASS: TestNodePublishVolume_ViaPod (2.14s)
PASS
ok github.com/yourname/localdir-csi/test/integration 2.143s
One more real wrinkle worth naming, hit while confirming this test
back-to-back with itself in a tight loop: a PersistentVolumeClaim
doesn't finish deleting the instant Delete returns — a
pvc-protection finalizer holds it in Terminating until the
underlying volume is actually gone, which for this driver means
DeleteVolume has to run first. Reusing a fixed claim name across
runs, with a cleanup that fires Delete and moves on without checking
whether the object is actually gone, can transiently hit object is being deleted: ... already exists on the next run's Create if the
previous run's Terminating claim hasn't finished clearing yet. That's
exactly why both this test's and TestCreateVolume_ViaPVC's cleanups
call waitFor on the delete, not just the create — a cleanup that
returns before the object is actually gone isn't really "cleaned up,"
it's "asked to clean up and hoped." Combined with uniqueName, a
Terminating leftover from a genuinely interrupted run (a killed test
process, say) still can't collide with anything a later run creates,
even if it clears too slowly to matter to that later run at all.
The regression test: CreateSnapshot, called twice
This is the test this chapter exists to write. Chapter 10's live
incident, replayed exactly, as TestCreateSnapshot_IdempotentRetry in
test/integration/snapshot_test.go. Calling the driver directly by
grpcurl — run inside the controller pod via the same execInPod
helper above, rather than through a VolumeSnapshot object — keeps
this test aimed precisely at the bug — a VolumeSnapshotContent retry
loop would eventually paper over a slow second call in ways that would
make a real regression harder to catch, not easier. Finding the
controller pod itself is one more client-go list call, by the same
label kubectl logs -l app=localdir-csi-controller has used by hand
since Chapter 7:
//go:build integration
package integration
import (
"context"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
func controllerPodName(t *testing.T, clientset *kubernetes.Clientset) string {
t.Helper()
pods, err := clientset.CoreV1().Pods("default").List(t.Context(), metav1.ListOptions{
LabelSelector: "app=localdir-csi-controller",
})
if err != nil {
t.Fatalf("listing controller pods: %v", err)
}
if len(pods.Items) == 0 {
t.Fatal("no localdir-csi-controller pod found — is the driver deployed?")
}
return pods.Items[0].Name
}
With that in place, the regression test itself:
//go:build integration
package integration
import (
"context"
"fmt"
"testing"
)
func TestCreateSnapshot_IdempotentRetry(t *testing.T) {
clientset, restConfig := requireCluster(t)
ctx := t.Context()
podName := controllerPodName(t, clientset)
uploadGrpcurl(ctx, t, restConfig, clientset, "default", podName, "localdir-csi")
volumeID := uniqueName("it-snap-source")
snapshotID := uniqueName("it-snap-demo")
grpcurl := func(ctx context.Context, data, method string) string {
return execInPod(ctx, t, restConfig, clientset, "default", podName, "localdir-csi", []string{
"/tmp/grpcurl", "-plaintext", "-d", data,
"unix:///csi/csi.sock", "csi.v1.Controller/" + method,
})
}
t.Cleanup(func() {
bg := context.Background()
grpcurl(bg, fmt.Sprintf(`{"snapshot_id": %q}`, snapshotID), "DeleteSnapshot")
grpcurl(bg, fmt.Sprintf(`{"volume_id": %q}`, volumeID), "DeleteVolume")
})
grpcurl(ctx, fmt.Sprintf(`{
"name": %q,
"capacity_range": {"required_bytes": 1048576},
"volume_capabilities": [{"mount": {}, "access_mode": {"mode": "SINGLE_NODE_WRITER"}}]
}`, volumeID), "CreateVolume")
execInPod(ctx, t, restConfig, clientset, "default", podName, "localdir-csi",
[]string{"sh", "-c", fmt.Sprintf("echo real data > /data/%s/hello.txt", volumeID)})
first := grpcurl(ctx, fmt.Sprintf(`{
"source_volume_id": %q,
"name": %q
}`, volumeID, snapshotID), "CreateSnapshot")
second := grpcurl(ctx, fmt.Sprintf(`{
"source_volume_id": %q,
"name": %q
}`, volumeID, snapshotID), "CreateSnapshot")
if first != second {
t.Errorf("CreateSnapshot retry returned a different response:\nfirst: %s\nsecond: %s", first, second)
}
}
uploadGrpcurl replaces the manual kubectl cp step from the cluster
setup section above — that step is still worth knowing (it's exactly
what uploadGrpcurl automates), but this test no longer depends on it
having been run, or on its result having survived whatever's happened
to the controller pod since.
grpcurl itself takes ctx as a parameter, for the exact same reason
execInPod does — this closure is called from both the test body
(where ctx is t.Context()) and from t.Cleanup (where it has to
be context.Background() instead). A closure that captured a single
fixed ctx from its enclosing scope, rather than taking one as a
parameter, would silently pick whichever context was in scope when the
closure was defined — the test body's, canceled by the time cleanup
runs — the same bug in a slightly more hidden shape.
first != second is the entire regression check, and it's worth being
precise about why that one comparison is enough. grpcurl's JSON
output includes creationTime, down to the nanosecond. Chapter 10's
real incident didn't fail this way — the pre-fix bug returned a real
gRPC error (Internal: ... file exists) on the second call, which
execInPod's t.Fatalf on a non-zero exit would already catch, before
the string comparison ever runs. The string comparison catches the
other failure mode a fix like this can quietly introduce: a second
call that "succeeds" but doesn't actually short-circuit — say, one
that recomputes sizeBytes from a directory listing instead of
returning the recorded metadata. That would return 0 OK both times,
pass a check that only asserted "no error," and still be wrong.
Comparing the full response, creationTime included, is what makes
this test check the same snapshot, not just no error — the exact
distinction Chapter 10's real live confirmation drew by hand when it
checked that the retry's creationTime matched the original down to
the nanosecond.
Run it now, the same way as the two tests above:
go test -tags=integration ./test/integration/... -run TestCreateSnapshot_IdempotentRetry -v
=== RUN TestCreateSnapshot_IdempotentRetry
--- PASS: TestCreateSnapshot_IdempotentRetry (0.24s)
PASS
ok github.com/yourname/localdir-csi/test/integration 0.252s
Real 0 OK twice, real identical creationTime, confirmed by
go test, on the exact bug this chapter set out to catch — worth
re-running a few times in a row rather than trusting one green result,
precisely because this section's own two fixes above were both found
by a previously passing-looking run turning out to be unreliable.
Five repeated runs, with go test -race (to keep the buffer fix
honest) and a manual cleanup between each:
go test -tags=integration -race ./test/integration/... -run TestCreateSnapshot_IdempotentRetry -v -count=1
--- PASS: TestCreateSnapshot_IdempotentRetry (0.31s)
--- PASS: TestCreateSnapshot_IdempotentRetry (0.31s)
--- PASS: TestCreateSnapshot_IdempotentRetry (0.31s)
--- PASS: TestCreateSnapshot_IdempotentRetry (0.30s)
--- PASS: TestCreateSnapshot_IdempotentRetry (0.29s)
Five for five, no race detected — the shared-buffer bug is really gone, not just quiet this particular run.
Running the whole suite together
Once all three tests exist, make integration-test runs them in one
pass — the same Makefile target added earlier in this chapter:
make integration-test
go test -tags=integration ./test/integration/... -v -timeout 5m
=== RUN TestNodePublishVolume_ViaPod
--- PASS: TestNodePublishVolume_ViaPod (2.62s)
=== RUN TestCreateVolume_ViaPVC
--- PASS: TestCreateVolume_ViaPVC (0.51s)
=== RUN TestCreateSnapshot_IdempotentRetry
--- PASS: TestCreateSnapshot_IdempotentRetry (0.24s)
PASS
ok github.com/yourname/localdir-csi/test/integration 3.384s
go test runs a package's tests in the order they appear in each
file, sorted by filename — TestNodePublishVolume_ViaPod
(mount_test.go) before TestCreateVolume_ViaPVC
(provisioning_test.go), alphabetically. All three real, all three
green, in one pass, against one real kind cluster.
Three tests, each skipping cleanly if run directly with go test and
no matching cluster around, but failing loudly if run via
make integration-test with the wrong context or no cluster at all:
TestCreateVolume_ViaPVC, TestNodePublishVolume_ViaPod, and
TestCreateSnapshot_IdempotentRetry — the last one doing, on every
run, exactly what a human happened to do once by accident back in
Chapter 10.
What you should have now
test/integration/, a//go:build integration-tagged package, invisible to the ordinarygo test ./...every earlier chapter's suite runsrequireCluster,waitFor, andexecInPod: three small helpers everything else in the package builds on, the same "one seam" shape as Chapter 11's interceptors — plusdemoPVC/demoPod, two object builders shared by every test that needs one, instead of the same struct literal written out twicek8s.io/client-goandk8s.io/apimachineryas new real dependencies — the first time this driver's owngo.modhas needed a Kubernetes client library, since every earlier chapter's Kubernetes interaction was either a sidecar's job or typed by handTestCreateVolume_ViaPVC: Chapter 8'sPersistentVolumeClaimprovisioning proof, automatedTestNodePublishVolume_ViaPod: Chapter 8's pod-mount proof, automated, keeping the same proof-from-both-sides technique — aclient-goexec stream inside the pod,docker execon the node's own disk (the one operation with noclient-goequivalent, named honestly rather than faked)TestCreateSnapshot_IdempotentRetry: a real regression test for the exact bug Chapter 10 hit live, comparing the full response of two identicalCreateSnapshotcalls rather than just checking for an error, so a future regression that "succeeds twice, quietly wrong" gets caught too, not just one that errors outright- An
integration-testMakefiletarget, the same convenience Chapter 9 added forcsi-sanity - Two real bugs in the test helpers themselves, found only by actually
running this chapter's tests against a real cluster repeatedly, not
by reading the code:
execInPod(and, following the same pattern,TestCreateSnapshot_IdempotentRetry'sgrpcurlclosure) takingctxas an explicit parameter instead of reaching fort.Context()internally, becauset.Context()is canceled before anyt.Cleanupruns; andexecInPodusing two separate buffers for stdout and stderr instead of one sharedbytes.Buffer, because two goroutines writing to the same non-thread-safe buffer is a real data race, confirmed withgo test -raceclean across five repeated runs once fixed. Neither bug was hypothetical — both reproduced live, against this book's ownkindcluster, before being fixed requireClusterrejecting any kubeconfig context other thankind-csi-dev, and any cluster missing this tutorial's ownCSIDriver, before any test can create or mutate anything — the suite no longer trusts "some cluster is reachable" to mean "this is the disposable one"uniqueName, used for every PVC, Pod, and CSI volume/snapshot name this chapter creates, so re-running the suite immediately — or after a previous run's cleanup only partially finished — can't collide with a leftover from before- Every cleanup now checks its own
Deletecall's error witht.ErrorfandwaitFors the object actually disappearing, instead of firing a delete and assuming it worked —pvc-protection's finalizer means "deleted" and "gone" aren't the same moment uploadGrpcurl, replacing a manually-copied/tmp/grpcurlthat a controller pod restart would silently wipe — the snapshot regression test now provisions its own copy of the binary it depends on, every runmake integration-testfailing outright, with a clear message, when the currentkubectlcontext isn'tkind-csi-dev— the tests themselves still skip cleanly for an ad hocgo testrun with no cluster around, but the one command a reader (or CI) actually invokes on purpose no longer reportsokhaving silently run zero real assertions
Two honest gaps remain. These tests aren't wired into any CI — they
need a real kind cluster with the driver already deployed, and this
book has never set up a CI pipeline to provide one; running them today
means running them by hand, the same way every other real-cluster
proof in this book has been run, just with go test instead of a
person reading kubectl output line by line. And coverage here is
deliberately narrow — three tests, not a full conformance suite
(that's what Chapter 9's csi-sanity already is) — chosen specifically
to cover the two flows this book has proven by hand the most
(provisioning and mounting) plus the one real bug this book has
actually hit live. A fuller integration suite covering every RPC this
driver has is possible with exactly these same helpers; it just isn't
written yet.