Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
053f702
feat(migrate): add LINSTOR k8s-backend dump loader
kvaps Jul 15, 2026
d5cf4c2
feat(migrate): LINSTOR->blockstor CRD converter + linstor-migrate CLI
kvaps Jul 15, 2026
f21affd
test(migrate): golden + calibration tests on a synthetic dump fixture
kvaps Jul 15, 2026
08e485e
feat(migrate): report non-migratable LUKS passphrases
kvaps Jul 15, 2026
5c84809
feat(migrate): close case-coverage gaps found in production dumps
kvaps Jul 15, 2026
56626e7
feat(controller): adopted-snapshot gate — never re-orchestrate imports
kvaps Jul 15, 2026
d5aaff8
feat(migrate): preserve live DRBD ports to avoid reconnect on adoption
kvaps Jul 15, 2026
1af5424
docs(migrate): LINSTOR to blockstor migration runbook
kvaps Jul 15, 2026
0eef0c4
test(migrate): server-side CRD+CEL validation of converted output
kvaps Jul 15, 2026
1e90343
style(store): drop named returns from SplitProps (nonamedreturns lint)
kvaps Jul 15, 2026
72a654d
feat(migrate): drop dangling replicas/snapshots (referential integrity)
kvaps Jul 15, 2026
9504145
feat(migrate): drop storage pools on un-migrated nodes
kvaps Jul 15, 2026
3db4067
fix(satellite): register ZFS pools from StorDriver/StorPoolName (B1)
kvaps Jul 15, 2026
f124d2e
docs(migrate): add zvol-name cross-check + staging rehearsal (gate B2)
kvaps Jul 15, 2026
50aa509
test(migrate): pin zvol-name adoption invariant (gate B2)
kvaps Jul 15, 2026
a9c806a
feat(migrate): guard dangling RG refs + snapshot placements (PR review)
kvaps Jul 16, 2026
888c9b8
fix(migrate): address review findings on #180
kvaps Jul 20, 2026
30d30c4
docs+migrate: snapshot coverage/name checks from review follow-ups
kvaps Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@
# Test side-effects
.claude/
pkg/satellite/pvc-dispatch-adjust.res
linstor-migrate
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ linters:
- linters:
- tagliatelle
path: pkg/rest/
# pkg/linstormigrate mirrors the on-disk snake_case row schema of
# LINSTOR's k8s-backend database CRDs (*.internal.linstor.linbit.com).
# The JSON tags MUST match the dump format verbatim.
- linters:
- tagliatelle
path: pkg/linstormigrate/
# pkg/drbd/drbdsetup_show.go mirrors drbd-utils' on-the-wire JSON
# shape (`drbdsetup show -j`) which uses snake_case with leading
# underscores on private-implementation fields. The JSON tags
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration
build: manifests generate fmt vet ## Build controller + satellite binaries.
go build -o bin/controller ./cmd/controller
go build -o bin/satellite ./cmd/satellite
go build -o bin/linstor-migrate ./cmd/linstor-migrate

.PHONY: run
run: manifests generate fmt vet ## Run the controller from your host.
Expand Down
32 changes: 32 additions & 0 deletions api/v1alpha1/snapshot_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,38 @@ type SnapshotVolumeRef struct {
SizeKib int64 `json:"sizeKib"`
}

// AnnotationSnapshotAdopted marks a Snapshot whose on-disk
// materialisation ALREADY EXISTS on every targeted node — it was
// created by a previous storage controller (LINSTOR) and imported by
// a migration tool, not taken by blockstor. The controller-side
// SnapshotReconciler treats such a Snapshot as terminally complete:
// it backfills Status.NodeStatus[*].Ready=true for every Spec.Nodes
// entry and NEVER runs the suspend→take→resume orchestration —
// re-driving Phase 1 against an adopted Snapshot would freeze
// production I/O (drbdsetup suspend-io on every diskful peer) just to
// re-take a snapshot that is already on disk.
//
// Set this annotation ONLY when the backing snapshot verifiably
// exists on the listed nodes; stamping it on a Snapshot with no
// on-disk backing yields an object that lists as Successful but whose
// restore will fail with the provider's not-found error.
//
// The annotation is meant to be stamped at CREATION. The controller
// deliberately IGNORES it on a Snapshot that is already mid-flight
// (Spec.SuspendIO=true) and completes the normal orchestration
// instead: short-circuiting there would leave every diskful peer
// holding `drbdsetup suspend-io` with nothing left to resume it —
// frozen production I/O with no error surfaced.
const AnnotationSnapshotAdopted = "blockstor.io/adopted"

// AnnotationSnapshotAdoptedCreatedAt optionally carries the original
// creation time of an adopted snapshot as milliseconds since the Unix
// epoch (the shape LINSTOR's RESOURCES.create_timestamp column uses).
// The SnapshotReconciler copies it into the backfilled per-node
// CreateTimestamp entries so `linstor s l` keeps showing the REAL
// snapshot age instead of the adoption time.
const AnnotationSnapshotAdoptedCreatedAt = "blockstor.io/adopted-created-at"

// SnapshotStatusFlagFailed is stamped on Status.Flags by the
// satellite reconciler when CreateSnapshot returned a terminal
// error (e.g. parent volume missing, unknown resource, source
Expand Down
172 changes: 172 additions & 0 deletions cmd/linstor-migrate/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Command linstor-migrate converts a LINSTOR k8s-backend database dump
// into blockstor CRD manifests.
//
// Input is a directory of `kubectl get <table> -ojson` dumps of the
// `*.internal.linstor.linbit.com` CRDs LINSTOR uses as its database
// when running with the k8s connector:
//
// kubectl get crds | grep -o ".*.internal.linstor.linbit.com" | \
// xargs -I{} sh -c "kubectl get {} -ojson > {}.json"
//
// Output is a multi-document YAML stream of blockstor
// `blockstor.cozystack.io/v1alpha1` objects in apply order (nodes,
// storage pools, resource groups, resource definitions, resources,
// snapshots) on stdout (or -out <file>), with the migration report —
// row counts plus every skipped row / dropped flag bit — on stderr.
//
// Usage:
//
// linstor-migrate -in /path/to/dump [-out manifests.yaml]
package main

import (
"flag"
"fmt"
"io"
"os"

"github.com/cozystack/blockstor/pkg/linstormigrate"
)

func main() {
os.Exit(run())
}

func run() int {
var (
inDir = flag.String("in", "", "directory with *.internal.linstor.linbit.com.json table dumps (required)")
outPath = flag.String("out", "-", "write manifests to this file ('-' = stdout)")
portsPath = flag.String("drbd-ports", "", "optional '<rd-name> <port>' file of LIVE DRBD ports (see runbook); preserves the running mesh endpoint so adoption doesn't reconnect")
)

flag.Parse()

if *inDir == "" {
fmt.Fprintln(os.Stderr, "error: -in <dump-dir> is required")
flag.Usage()

return 2
}

err := convertToOutput(*inDir, *outPath, *portsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)

return 1
}

return 0
}

// convertToOutput runs the whole load → convert → write pipeline,
// writing manifests to outPath and the migration report to stderr.
func convertToOutput(inDir, outPath, portsPath string) error {
opts, err := buildOptions(portsPath)
if err != nil {
return err
}

dump, err := linstormigrate.LoadDump(inDir)
if err != nil {
return fmt.Errorf("load dump: %w", err)
}

result, err := linstormigrate.ConvertWithOptions(dump, opts)
if err != nil {
return fmt.Errorf("convert: %w", err)
}

out, closeOut, err := openOutput(outPath)
if err != nil {
return err
}

err = linstormigrate.WriteManifests(out, result)
if err != nil {
_ = closeOut()

return fmt.Errorf("write manifests: %w", err)
}

// Close BEFORE reporting success: a deferred close would run after
// the exit code is fixed, so an ENOSPC-style flush failure would
// leave a truncated manifest behind while the process still exited
// 0 — and a `linstor-migrate … && kubectl apply -f …` pipeline would
// apply the truncated stream as if it were complete.
err = closeOut()
if err != nil {
return err
}

err = linstormigrate.WriteReport(os.Stderr, result)
if err != nil {
return fmt.Errorf("write report: %w", err)
}

return nil
}

// buildOptions loads the optional live DRBD port map from portsPath
// (see linstormigrate.ParseDRBDPorts for the format). Empty path
// yields empty options.
func buildOptions(portsPath string) (linstormigrate.Options, error) {
if portsPath == "" {
return linstormigrate.Options{}, nil
}

data, err := os.ReadFile(portsPath)
if err != nil {
return linstormigrate.Options{}, fmt.Errorf("read %s: %w", portsPath, err)
}

ports, err := linstormigrate.ParseDRBDPorts(string(data))
if err != nil {
return linstormigrate.Options{}, fmt.Errorf("%s: %w", portsPath, err)
}

return linstormigrate.Options{DRBDPorts: ports}, nil
}

// openOutput resolves the -out flag: "-" streams to stdout (no-op
// closer), anything else creates the file. The returned closer reports
// its error so the caller can fail the run on a truncated write rather
// than exiting 0 with a partial manifest on disk.
func openOutput(path string) (io.Writer, func() error, error) {
if path == "-" {
return os.Stdout, func() error { return nil }, nil
}

file, err := os.Create(path)
if err != nil {
return nil, nil, fmt.Errorf("create %s: %w", path, err)
}

closeFile := func() error {
closeErr := file.Close()
if closeErr != nil {
return fmt.Errorf("close %s: %w", path, closeErr)
}

return nil
}

return file, closeFile, nil
}
123 changes: 123 additions & 0 deletions docs/linstor-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Migrating a LINSTOR cluster to blockstor

`linstor-migrate` converts a LINSTOR k8s-backend database dump into blockstor CRDs. blockstor then **adopts** the existing on-disk data (zvols/LVs + DRBD metadata) in place — no data is copied or resynced. This runbook is the required procedure; the converter is only one step of it.

> **Scope.** This adopts a LINSTOR deployment whose satellites already run on the nodes blockstor will manage, keeping the same storage pools, DRBD minors, node-ids and (with the port capture below) TCP ports. It does not move data between nodes and does not change replica placement.

## What the converter does and does not migrate

Migrated: nodes, storage pools, resource groups, resource definitions (+ volume definitions), resources (replicas), and SUCCESSFUL snapshots. Controller-wide `DrbdOptions/*` become a `ControllerConfig`. DRBD minors, per-replica node-ids, the shared-secret and (when captured) TCP ports are preserved verbatim. Every RD is stamped `Initialized=true` and every replica `skipInitialSync=false`, so any replica added **after** migration performs a real sync instead of falsely coming up UpToDate. Snapshots are stamped `blockstor.io/adopted` so the controller records them as complete without re-taking them.

**NOT migrated — read the report on stderr for the exact list per cluster:**

- **LUKS passphrases.** LUKS-encrypted volumes convert with their layer stack intact, but the per-volume passphrase (encrypted with the LINSTOR master key) is not decrypted. You must provision `spec.encryption` for those RDs by hand before the encrypted volumes can be opened. Each affected RD is reported.
- **Backup-shipping remotes**, unknown/runtime flag bits, and DELETE'd / FAILED_DEPLOYMENT rows are skipped and reported. None affect live data availability.
- **DRBD TCP ports** are absent from most LINSTOR 1.33 dumps — see "Capture live DRBD ports" below.

## Pre-flight (do all of these before touching anything)

1. **Cluster is healthy.** `linstor r l --faulty` is empty; every resource is `UpToDate` on its diskful replicas; no node is `OFFLINE`. Do not migrate a degraded cluster — an Inconsistent/SyncTarget replica adopted as-is stays that way.

2. **Take the database dump** (LINSTOR's k8s-backend CRDs):

```bash
mkdir dump && cd dump
kubectl get crds | grep -o ".*.internal.linstor.linbit.com" \
| xargs -I{} sh -c 'kubectl get {} -ojson > {}.json'
cd ..
```

3. **Capture the live DRBD ports.** LINSTOR 1.33 does not persist the port in its database, so the dump alone cannot preserve it. If blockstor adopts with a different port, `drbdadm adjust` moves the connection endpoint under live I/O (a reconnect blip, and on a marginal quorum a transient quorum loss). Capture the running ports from every satellite and union them:

```bash
for pod in $(kubectl -n cozy-linstor get pods -l app.kubernetes.io/component=linstor-satellite \
-o jsonpath='{.items[*].metadata.name}'); do
kubectl -n cozy-linstor exec "$pod" -- sh -c '
for f in /var/lib/linstor.d/*.res; do
rd=$(basename "$f" .res)
port=$(grep -oE "address[^;]*:[0-9]+" "$f" | grep -oE "[0-9]+$" | head -1)
[ -n "$port" ] && echo "$rd $port"
done'
done | sort -u > drbd-ports.txt
```

Every replica of an RD shares one port, so duplicates across nodes are expected; `sort -u` collapses them. If an RD appears with two different ports, stop and investigate before proceeding.

4. **Back up the ZFS/LVM pools' snapshots** (optional but recommended): a `zfs snapshot -r` of each pool gives an instant rollback point that is independent of both control planes.

5. **Verify the zvol names blockstor will adopt match what is on disk.** Adoption is name-based: blockstor addresses each volume as `<zpool>/<rd-name-lowercased>_<volume-number-%05d>` (e.g. `data/pvc-abc…_00000`), and `CreateVolume` is idempotent only when that dataset already exists. A byte mismatch (ZFS names are case-sensitive) makes blockstor create a fresh EMPTY zvol next to the real one. Multi-replica DRBD would self-heal by SyncTarget, but **single-replica `["STORAGE"]` volumes have no resync fallback** — a mismatch there silently presents an empty disk. Cross-check before cutover, read-only, on each satellite:

```bash
# what is on disk:
zfs list -H -o name -t volume | sort > /tmp/ondisk-zvols.txt
# what blockstor will look for (from the converted manifests):
# <zpool>/<metadata.name of each Resource>_<vol %05d>
# compare the two sets; every single-replica STORAGE-only volume MUST
# already exist on disk under the blockstor name.
```

If any single-replica volume's computed name is absent from `ondisk-zvols.txt`, STOP — do not cut over until the naming is reconciled (`zfs rename`, or fix the converter).

**Adopted snapshots are addressed the same way** — `<zpool>/<rd-name-lowercased>_00000@<snapshot-name>` — so run the same cross-check for them, otherwise a name miss only surfaces when someone tries to restore, long after cutover:

```bash
zfs list -H -o name -t snapshot | sort > /tmp/ondisk-snapshots.txt
# every migrated Snapshot's <zpool>/<rd>_00000@<snap> must appear here
```

6. **Rehearse on staging first.** Do a full dry-run adoption of a COPY of the pools (or a representative subset) on a non-production cluster: apply the manifests, confirm every StoragePool registers a provider (no `provider requires … in props` errors in the satellite log), every Resource reaches `UpToDate` with `out-of-sync=0` (adopted, not resynced), and no new empty zvols appear. Only after a clean staging rehearsal proceed to production.

## Convert

```bash
linstor-migrate -in ./dump -drbd-ports ./drbd-ports.txt -out ./blockstor-manifests.yaml
```

Read the report on stderr end to end. Every `warning:` line is a thing the converter refused to guess — decide per line whether it matters for your data before continuing. In particular resolve every `LUKS passphrase NOT migrated` and every `no DRBD port` line (the latter should be empty if the port capture was complete).

## Cutover

The manifests do not migrate a cluster by themselves — the control plane must be swapped. Perform this in a maintenance window.

1. **Freeze provisioning.** Scale the CSI provisioner/attacher to zero (or cordon new PVC creation) so no new volumes are created against the old controller mid-cutover.

2. **Stop the LINSTOR controller.** Scale `deploy/linstor-controller` to 0. The satellites and the running DRBD devices keep serving I/O — only the control plane goes away. Do NOT stop the satellites or unload DRBD.

3. **Deploy blockstor** (apiserver + controller + satellites) pointed at the same nodes and pools. The satellites must land on the same nodes with the same pool backing devices. Do not let blockstor create-md or mkfs — the adoption guards (existing DRBD metadata is not re-created, a populated filesystem is not re-formatted) protect the data, but verify the satellite image is the intended version first.

4. **Apply the manifests in order.** `WriteManifests` already emits them in dependency order, so a single apply works:

```bash
kubectl apply -f ./blockstor-manifests.yaml
```

If you prefer staged application, the order is: `ControllerConfig` → `Node` → `StoragePool` → `ResourceGroup` → `ResourceDefinition` → `Resource` → `Snapshot`.

5. **Wait for adoption to converge.** Every `Resource` should report its DRBD device `UpToDate` (adopted, not resynced — watch that `out-of-sync` stays 0, i.e. no unexpected full sync started). `linstor r l --faulty` against the blockstor apiserver is empty. Storage pools report their real free capacity.

6. **Repoint and unfreeze CSI.** Point linstor-csi at the blockstor apiserver, then scale the provisioner/attacher back up. Provision one test PVC and confirm it binds and that an existing PVC still mounts read-write.

## Verify (before declaring done)

- Object counts match the source: `linstor n l`, `sp l`, `rg l`, `rd l`, `r l` against blockstor equal the pre-cutover counts (the converter's report prints them).
- Spot-check ≥5 RDs: size, per-volume `/dev/drbd<minor>`, per-replica node-id and port match the pre-migration values. A changed minor or node-id is a stop-the-line defect.
- Every diskful replica is `UpToDate`; no replica is unexpectedly `SyncTarget`.
- Adopted snapshots list as present without any suspend-io having fired on the parent RDs (check the satellites did not log `suspend-io`).
- For LUKS RDs: their volumes stay closed until you provision `spec.encryption`; confirm consumers of those volumes are quiesced until then.

## Rollback

Until CSI is repointed and confirmed, rollback is: scale blockstor to 0, scale `deploy/linstor-controller` back to 1, repoint CSI at LINSTOR. The on-disk data and running DRBD devices were never touched, so this is a control-plane-only revert. If a pool-level `zfs snapshot` was taken in pre-flight, it is the last-resort data rollback point.

## Known limitations (accept or mitigate explicitly)

| Item | Impact | Mitigation |
| --- | --- | --- |
| LUKS passphrases not migrated | Encrypted volumes cannot be opened until `spec.encryption` is provisioned | Provision by hand; keep those consumers quiesced during migration |
| DRBD port not in dump (LINSTOR 1.33) | Adoption reconnects the mesh on a new port if not captured | Capture with the pre-flight command and pass `-drbd-ports` |
| Unknown flag bits dropped | Cosmetic runtime bookkeeping only | Reported; verified not to carry availability semantics |
| Placement/data unchanged | The converter never moves data | Rebalance with blockstor after migration if desired |
| Single-replica zvol name must byte-match on disk | A mismatch presents an empty disk (no DRBD resync fallback) | Pre-flight step 5 cross-check + staging rehearsal (step 6) |
| Snapshots cover volume 0 only | blockstor addresses a snapshot as `<zpool>/<rd>_00000@<snap>`; a snapshot that captured a multi-volume resource adopts with only its first volume restorable | Reported per snapshot by the converter; re-take those snapshots after migration if the other volumes matter |
| Undecoded flag bits are reported, not interpreted | A `non-zero vlm_flags` / `node_flags` / `unhandled flags bits` line means the source cluster had a marker this tool refuses to guess at (e.g. a size-semantics or eviction bit) | Look the object up in the source cluster before cutover (`linstor v l` / `n l`) and confirm it should be adopted as-is; the volume's `SizeKib` is carried verbatim either way |
| Node type mapping is empirically calibrated | Only `node_type=2` (SATELLITE) was confirmed against a live cluster; a CONTROLLER/AUXILIARY ordering error would adopt a controller node as AUXILIARY instead of skipping it | Controller nodes carry no pools or replicas, so impact is bounded — but check the report for unexpected node kinds |
Loading