Skip to content

Persistent storage (CSI)

Stateful workloads need persistent volumes. CAPVCO ships the whitesky.cloud CSI driver as an add-on, so a PersistentVolumeClaim becomes a real whitesky.cloud disk, attached to the node that needs it. You get standard disks, low-latency physical disks, online expansion, and — with a key vault — LUKS2 at-rest encryption.

Turn it on

Add the CSI label to the Cluster (the base, prebaked and geo templates already do):

metadata:
  labels:
    capvco.infrastructure.cluster.x-k8s.io/csi: vco

CAPVCO then, per cluster:

  1. deploys the driver (controller Deployments + a node DaemonSet) into the csi-driver namespace via a ClusterResourceSet, and
  2. injects a per-cluster credential Secret (csi-driver-secret) so the driver can create/attach disks with your cluster credential — CAPVCO mints nothing.

Install the CSI add-on once — like the CNI add-on

Like the CNI, the CSI driver is delivered by a ClusterResourceSet you apply once to the management cluster; every cluster labelled csi: vco then gets the driver automatically (this is the "per cluster" deploy above). The driver is the existing whitesky.cloud CSI driver, not a rewrite. Two things about it are per-instance (white-label), so the bundle carries placeholders you substitute at apply time: its image (${VCO_CSI_IMAGE}, host = your portal) and its name (${VCO_CSI_DRIVER_NAME}). The driver names itself from the portal domain — reverse the domain and replace the host leaf with csi (e.g. try.whitesky.cloud → cloud.whitesky.csi) — and the shipped CSIDriver object and StorageClass provisioner must use that exact name, so PVCs bind (#21).

From a release (csi.yaml ships alongside cni.yaml):

host=<your-portal-host>                # e.g. try.whitesky.cloud
driver=$(echo "$host" | awk -F. '{o="";for(i=NF;i>1;i--)o=o $i".";print o"csi"}')   # -> cloud.whitesky.csi
curl -fsSL https://gitlab.whitesky.cloud/api/v4/projects/730/packages/generic/capvco/v0.6.1/csi.yaml \
  | VCO_CSI_IMAGE="$host/csi-driver:latest" VCO_CSI_DRIVER_NAME="$driver" \
    envsubst '$VCO_CSI_IMAGE $VCO_CSI_DRIVER_NAME' \
  | kubectl apply -f -
From a checkout: make csi-addon VCO_CSI_IMAGE=<your-portal-host>/csi-driver:latest | kubectl apply -f - (it derives the driver name from the image host automatically).

StorageClasses

Three classes ship, with stable, instance-independent names (vco-csi*). Their provisioner is the per-instance driver name, substituted at apply time (above), so they match your running driver on any instance:

StorageClass Backing Notes
vco-csi Standard SDS disk Default class. WaitForFirstConsumer, allowVolumeExpansion: true.
vco-csi-physical Physical (local, low-latency) disk diskType: PHYSICAL. For latency-sensitive workloads.
vco-csi-encrypted-vault Standard disk + LUKS2 at-rest Requires a key vault (below) and the encryption add-on.

If a PVC sits Pending with no error, check the driver name matched

Provisioning is silent when the StorageClass provisioner doesn't equal the driver's advertised name — the external-provisioner just ignores the PVC (#21). The apply step above substitutes the name derived from your portal domain; verify it matches what the driver actually registered:

# against the WORKLOAD cluster — should equal the derived name (e.g. cloud.whitesky.csi)
kubectl get csinode -o jsonpath='{range .items[*].spec.drivers[*]}{.name}{"\n"}{end}'
kubectl get storageclass vco-csi -o jsonpath='{.provisioner}{"\n"}'
If they differ, your instance's name doesn't follow the reverse-domain rule — set VCO_CSI_DRIVER_NAME=<the-name-from-csinode> explicitly when applying the add-on.

A basic PVC just uses the default class:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 10Gi
  # storageClassName omitted → the default class

WaitForFirstConsumer means the disk is created in the right location once a pod actually schedules — so the volume lands where the workload runs.

ReadWriteOnce is single-node — enforced, but plan for it

Volumes are ReadWriteOnce: the disk attaches to one node at a time. Pods on the same node share it fine. If a second pod lands on a different node, Kubernetes enforces single-node attach — the second pod is held with a clean Multi-Attach error for volume … and waits for the first to release, rather than double-mounting (no corruption path). This is real (a VolumeAttachment is tracked and the CSIDriver's attachRequired is honoured) and is validated on a multi-worker cluster by the feature-matrix e2e (#22).

So the failure mode is a pod that waits, not data loss — but design for it: for a disk per replica use a StatefulSet with volumeClaimTemplates; for a single-owner volume on a Deployment pin replicas: 1 (or strategy: Recreate) so a rolling update doesn't strand the new pod behind the old one's attachment.

Provisioned PVs carry no topology nodeAffinity, so when the scheduler places a pod whose volume already exists it gets no signal about where that volume lives. Placement happens before any CSI call, so the driver cannot compensate. Within one location that only costs time — the pod waits for the old node to detach. Across locations it cannot recover: a whitesky.cloud disk is location-scoped, so on a geo cluster (worker pools pinned to site-1/site-2/site-3, which may sit in different locations) a pod placed in a different site from its volume waits indefinitely. Until the driver reports accessible_topology at location granularity (the remaining driver-side item in #22), keep volume-bound workloads in one location with a nodeSelector on the driver's own node label:

nodeSelector:
  cloud.whitesky/location: <location>   # same location the volume was created in

Online expansion

The default and physical classes allow expansion. Grow a bound PVC by editing its request:

kubectl patch pvc data -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'

The disk and filesystem grow online, no pod restart.

No volume snapshots yet

A VolumeSnapshotClass / CSI snapshotter is not shipped in this release (it needs the snapshot CRDs + snapshot-controller, which are deferred). Don't rely on CSI snapshots for now; for cluster-level disaster recovery use etcd backup. Tracked in known limitations.

Encrypted volumes (LUKS2)

The …csi-encrypted-vault class encrypts data at rest with LUKS2, wrapping the volume key with a Vault/OpenBao transit engine. You choose the vault mode on the VCOCluster:

spec:
  storage:
    encryption:
      vault: external      # none | external | managed
Mode What it does Status
none (default) No vault, no encrypted class — only standard/physical ship. ✅
external You bring a Vault/OpenBao; CAPVCO wires the driver to it. ✅ Supported
managed CAPVCO deploys and operates a Vault for you. ⏳ Deferred — see known limitations

For external, point at your vault:

spec:
  storage:
    encryption:
      vault: external
      external:
        address: https://openbao.example:8200
        transitMount: transit
        k8sAuthPath: kubernetes
        role: csi
        caRef:
          name: openbao-ca      # ConfigMap/Secret with the vault CA bundle

The encryption add-on (csi-encryption: openbao) then delivers the csi-key-backend-config and openbao-ca ConfigMaps the driver reads. The driver authenticates to the vault with its Kubernetes ServiceAccount token (via the vault's kubernetes-auth), so no static vault credential lives in the cluster.

Per-cluster vault auth is a manual step (for now)

Because auth is per-cluster, your vault needs a kubernetes-auth role (bound to the CSI ServiceAccounts, pointing at this cluster's TokenReview endpoint + CA) before encrypted volumes work. Operating the vault and wiring a cluster is covered in OpenBao operations. Automating this per-cluster is a follow-up.

Driver image must include cryptsetup

LUKS formatting needs cryptsetup in the CSI node plugin image. All of CAPVCO's provider-side wiring (transit login, encrypt/decrypt, the encrypted class) is validated end-to-end, but the reference …/csi-driver:latest image historically shipped without cryptsetup, in which case encrypted volumes fail to format. Confirm your driver image includes it. Tracked in known limitations.

How node ↔ disk identity works

The CSI node plugin resolves which whitesky.cloud VM it's running on from the host's SMBIOS UUID (/sys/class/dmi/id/product_uuid), matched against the VM's reference id — not from the Kubernetes providerID. So CSI works independently of the provider-id scheme, and there's nothing for you to configure here.

Security & isolation caveat

The injected csi-driver-secret holds your cluster credential. A tenant admin in the workload cluster can read that Secret and use the credential directly against the whitesky.cloud API — so the credential's grant scope is the workload cluster's blast radius. Scope the per-cluster credential to just what one cluster needs.

CSI disk-create needs a location-scoped grant

The driver creates disks through a location-scoped API path. A credential granted only at the cloudspace level can attach/snapshot but cannot create disks (a genuine 403). So a cluster running CSI needs a credential with a location grant — which spans every cloudspace in that location, slightly widening the blast radius beyond one cloudspace. This is the one isolation trade-off in the current design; spec.storage.credentialScope exists to select it (location is the default and the working option today). See known limitations.


Next: give etcd its own fast disk, or grow node disks automatically → Node disks.