Skip to content

fix(rest): unbreak CSI clone-from-volume and make snapshot restore idempotent - #190

Open
Andrei Kvapil (kvaps) wants to merge 30 commits into
mainfrom
fix/csi-clone-and-restore-idempotency
Open

Andrei Kvapil (kvaps) wants to merge 30 commits into
mainfrom
fix/csi-clone-and-restore-idempotency

Conversation

@kvaps

@kvaps Andrei Kvapil (kvaps) commented Sep 2, 2026

Copy link
Copy Markdown
Member

Two independent CSI-facing defects reported from a Cozystack stand running the blockstor backend.

Clone rejected every request golinstor sends

The endpoint declared five fields and decodes with DisallowUnknownFields, so layer_list, which linstor-csi defaults to [DRBD, STORAGE] and never omits, was a 400 before any of the handler ran. Every clone-from-volume failed whatever the StorageClass said, and on Cozystack the platform-wide cloneStrategyOverride: csi-clone routes every disk clone through here, so a VMDisk with a source.disk sat in CSICloneInProgress indefinitely. resource_group was the next 400 waiting behind it, and delete_namespaces the one after that. It rides with override_props and delete_props on every props-modify body upstream sends, so declaring its two neighbours and not it left a clone body carrying it on the same refusal. The triple is now embedded from golinstor rather than respelled, since respelling is how it went missing.

The two fields that carry meaning are honoured on both clone paths: the volume-less shortcut and the snapshot-restore path CSI actually takes. The stack is validated the way rg modify validates its own, asked for the LUKS prerequisite like every other writer of a layer stack, and the resource group is checked to exist: it lands on the target on both paths and went past no validator, so a typo produced a clone whose parent group does not exist. It lists fine and places badly, because the placer's Controller→RG→RD prop walk drops the RG tier without a word.

external_name and volume_passphrases are refused rather than accepted and dropped. Ignoring the first returns a definition under a name the caller did not ask for; ignoring the second materialises volumes with keys the caller does not hold. That is the failure mode src_snap_name is already refused for on this endpoint.

layer_list is honoured with one refusal, and it is the reason to read this section twice: on a source that has volumes, the requested stack must be the source's, compared as a set, since order is the stack's own and case folds. The clone data plane restores the source's bytes and brings the layer stack up over them, in that order, and every layer's bring-up writes to the device it is handed: luks.Format treats a device with no LUKS header as one to format, and create-md runs with --force over meta-disk internal, stamping metadata across the tail of the same bytes. So a layer added here lands on the data the clone just restored and the clone reports COMPLETE over it, and a layer dropped leaves the target reading data the missing layer wrote. A source with no recorded stack means the upstream default [DRBD, STORAGE], resolved the way every other reader resolves it, because reading it as "no layers" would make linstor-csi's own request look like adding both and refuse every clone-from-volume. A volume-less source has no bytes to lose and still takes any stack.

Snapshot restore was not idempotent

CSI requires CreateVolume to be idempotent, and external-provisioner has no other way to make progress after a partial failure. A restore that created the ResourceDefinition and then failed left it behind, so every retry answered object already exists, so the first partial failure was terminal for that volume name, the PVC stayed Pending, and the leftover had to be deleted by hand.

A repeat now resumes when the definition under that name carries this restore's own marker. The marker is stamped with the definition, before the volumes are hydrated and the replicas placed, so it says the restore STARTED and not that it finished, and answering success on it would trade a terminal failure for a silent incomplete one, with CSI seeing a ready volume nothing ever finishes. The clone path now draws the same split: it used to answer 201 "already cloned" on the marker alone, so the claim that it already did was wrong, and this PR is where it becomes true.

Three things stay refusals on both paths: a definition under that name this operation did not create; its own leftover carrying the DELETE flag, since finishing that one races the tear-down reaping what it writes; and a retry asking for a different shape than the attempt that created the leftover, because resuming keeps that leftover and the request's resource_group would be validated and then dropped. A leftover internal snapshot that has fallen behind the source (a volume added, or resized) is refused rather than cloned from, since resuming over it materialises the clone at the old shape and reports it complete. And the marker comparison is the equality LINSTOR itself uses, in one place, on both doors: LINSTOR folds name case, so a retry arriving as --from-snapshot SNAP over a marker written snap, or under an uppercase clone target, read as somebody else's definition and was refused, which is the terminal-on-first-failure behaviour the resume path exists to end.

A restore into a definition carrying the DELETE flag is refused on the volume-definition endpoint too, which fetched the target and threw it away.

This does not address whatever failed the first attempt, which the report could not pin down either. It makes that failure recoverable instead of terminal.

Testing

Every behavioural change here is pinned by a test that was checked by reverting the change and confirming the named test goes red, including the two directions of the LUKS refusal, the resumed clone (whose status is 201 either way, so only the volumes tell a resumed clone from one that merely agreed it had happened), the DELETE-flagged leftover on both paths, the case-folded retry, and the unknown resource group on both clone paths. The refusal tests carry positive controls, because a refusal test that passes for the wrong reason is the failure mode two of the earlier tests on this endpoint had.

The case-folding test needs a store that folds names the way the Kubernetes one does, which the in-memory store does not, so it runs through a decorator that models exactly that.

docs/cli-parity-known-deltas.md gains rows 86 and 87 for the refusals and the retry semantics.

golangci-lint is clean on the touched files; the pkg/rest suite passes apart from a pre-existing server did not stop within 2s after cancel flake that reproduces unchanged on the merge base.

Summary by CodeRabbit

  • New Features

    • Clone requests support layer-stack and resource-group options, with property and namespace overrides applied during restoration.
    • Incomplete clones and snapshot restores can resume under the same target name.
    • Completed clones can be safely retried as point-in-time copies.
  • Bug Fixes

    • Added validation for incompatible layers, stale snapshots, unknown resource groups, and unsafe targets.
    • Unsupported external names and volume passphrases return clear not-supported responses.
    • Retries handle case differences and avoid overwriting unrelated resources.
  • Documentation

    • Documented clone and snapshot-restore retry behavior and parity limitations.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants