diff --git a/.gitignore b/.gitignore
index 6a4d8d40..bebae282 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@
# Test side-effects
.claude/
pkg/satellite/pvc-dispatch-adjust.res
+linstor-migrate
diff --git a/.golangci.yml b/.golangci.yml
index f51d5c7a..53774643 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -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
diff --git a/Makefile b/Makefile
index bc1c960c..ae5a8afd 100644
--- a/Makefile
+++ b/Makefile
@@ -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.
diff --git a/api/v1alpha1/snapshot_types.go b/api/v1alpha1/snapshot_types.go
index f07576d4..187d0336 100644
--- a/api/v1alpha1/snapshot_types.go
+++ b/api/v1alpha1/snapshot_types.go
@@ -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
diff --git a/cmd/linstor-migrate/main.go b/cmd/linstor-migrate/main.go
new file mode 100644
index 00000000..fdd4f094
--- /dev/null
+++ b/cmd/linstor-migrate/main.go
@@ -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
-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 ), 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 ' ' 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 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
+}
diff --git a/docs/linstor-migration.md b/docs/linstor-migration.md
new file mode 100644
index 00000000..4c5da1e1
--- /dev/null
+++ b/docs/linstor-migration.md
@@ -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 `/_` (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):
+ # /_
+ # 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** — `/_00000@` — 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 /_00000@ 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`, 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 `/_00000@`; 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 |
diff --git a/internal/controller/snapshot_adopted_test.go b/internal/controller/snapshot_adopted_test.go
new file mode 100644
index 00000000..f77c78ac
--- /dev/null
+++ b/internal/controller/snapshot_adopted_test.go
@@ -0,0 +1,132 @@
+// 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.
+*/
+
+package controller
+
+import (
+ "context"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
+)
+
+// Adopted snapshots (imported from LINSTOR by a migration tool) carry
+// AnnotationSnapshotAdopted and an EMPTY Status. Before the adoption
+// gate, Reconcile fed them into nextPhase, whose false/false branch —
+// with no per-node acks recorded — decided the orchestration had not
+// started yet and flipped Spec.SuspendIO=true: every diskful peer of
+// the parent RD would freeze production I/O (drbdsetup suspend-io)
+// just to re-take a snapshot that already exists on disk. The gate
+// must instead backfill the terminal Ready state and leave the Spec
+// flags alone. This spec FAILS on the pre-gate controller (SuspendIO
+// observably flips true) and passes with the gate.
+var _ = Describe("Snapshot Controller adopted-snapshot gate", func() {
+ Context("When reconciling an adopted Snapshot with empty status", func() {
+ const resourceName = "rd7.adopted1"
+
+ ctx := context.Background()
+
+ typeNamespacedName := types.NamespacedName{
+ Name: resourceName,
+ Namespace: "default",
+ }
+
+ BeforeEach(func() {
+ By("creating the adopted Snapshot")
+
+ existing := &blockstoriov1alpha1.Snapshot{}
+
+ err := k8sClient.Get(ctx, typeNamespacedName, existing)
+ if err != nil && errors.IsNotFound(err) {
+ resource := &blockstoriov1alpha1.Snapshot{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: "default",
+ Annotations: map[string]string{
+ blockstoriov1alpha1.AnnotationSnapshotAdopted: "true",
+ blockstoriov1alpha1.AnnotationSnapshotAdoptedCreatedAt: "1770000000123",
+ },
+ },
+ Spec: blockstoriov1alpha1.SnapshotSpec{
+ ResourceDefinitionName: "rd7",
+ SnapshotName: "adopted1",
+ Nodes: []string{"node-x", "node-y"},
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &blockstoriov1alpha1.Snapshot{}
+
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ if err == nil {
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ }
+ })
+
+ It("must not start the suspend orchestration and must backfill Ready", func() {
+ controllerReconciler := &SnapshotReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ }
+
+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ var snap blockstoriov1alpha1.Snapshot
+
+ Expect(k8sClient.Get(ctx, typeNamespacedName, &snap)).To(Succeed())
+
+ By("leaving the orchestration flags alone (no production I/O freeze)")
+ Expect(snap.Spec.SuspendIO).To(BeFalse(),
+ "adopted snapshot must never enter Phase 1 (suspend-io)")
+ Expect(snap.Spec.TakeSnapshot).To(BeFalse(),
+ "adopted snapshot must never be re-taken")
+
+ By("backfilling the terminal per-node Ready state")
+ Expect(snap.Status.NodeStatus).To(HaveLen(2))
+
+ for _, entry := range snap.Status.NodeStatus {
+ Expect(entry.Ready).To(BeTrue())
+ Expect(entry.CreateTimestamp).To(Equal(int64(1770000000123)))
+ Expect(entry.SuspendIOAcked).To(BeFalse())
+ }
+
+ By("staying terminal on a second reconcile (idempotent)")
+ _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(k8sClient.Get(ctx, typeNamespacedName, &snap)).To(Succeed())
+ Expect(snap.Spec.SuspendIO).To(BeFalse())
+ Expect(snap.Status.NodeStatus).To(HaveLen(2))
+ })
+ })
+})
diff --git a/internal/controller/snapshot_controller.go b/internal/controller/snapshot_controller.go
index 9dfad3b3..265db21b 100644
--- a/internal/controller/snapshot_controller.go
+++ b/internal/controller/snapshot_controller.go
@@ -20,6 +20,7 @@ package controller
import (
"context"
+ "strconv"
"time"
"github.com/cockroachdb/errors"
@@ -103,6 +104,31 @@ func (r *SnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{}, nil
}
+ // Adopted snapshots (imported from another storage controller by
+ // a migration tool; see AnnotationSnapshotAdopted) already exist
+ // on disk. NEVER run the suspend→take→resume orchestration for
+ // them — Phase 1 would freeze production I/O on every diskful
+ // peer just to re-take a snapshot that is already materialised.
+ // Instead backfill the terminal per-node state once and stop.
+ //
+ // SAFETY: the gate is refused on a Snapshot that is already
+ // mid-orchestration (SuspendIO=true). Short-circuiting there would
+ // strand `drbdsetup suspend-io` on every diskful peer forever —
+ // nothing else ever flips SuspendIO back to false — i.e. frozen
+ // production I/O with no error surfaced. The annotation is only
+ // ever stamped at creation by the migration tool, so reaching this
+ // branch means operator misuse; fall through to the normal
+ // orchestration, which drives the in-flight snapshot to its
+ // terminal state (and resumes I/O) instead.
+ if snap.Annotations[blockstoriov1alpha1.AnnotationSnapshotAdopted] == labelTrueValue {
+ if snap.Spec.SuspendIO {
+ logger.Info("ignoring adopted annotation on an in-flight snapshot; completing normal orchestration so suspended I/O resumes",
+ "suspendIO", true)
+ } else {
+ return r.backfillAdoptedSnapshot(ctx, logger, &snap)
+ }
+ }
+
// b353: when Spec.GroupID is non-empty, the Snapshot participates
// in a transactional multi-RD batch — phase advancement gates on
// every sibling's per-node state, not just self. Empty GroupID
@@ -143,6 +169,100 @@ func (r *SnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.advancePhase(ctx, logger, &snap, siblings, next)
}
+// backfillAdoptedSnapshot stamps the terminal per-node state onto an
+// adopted Snapshot (see AnnotationSnapshotAdopted): every Spec.Nodes
+// entry gets a Ready=true NodeStatus row, with CreateTimestamp taken
+// from AnnotationSnapshotAdoptedCreatedAt when the annotation carries
+// a parseable ms-epoch value. Idempotent — once every targeted node
+// has its Ready row the reconcile is a no-op, so the adopted object
+// settles into the same terminal shape a successfully-taken snapshot
+// ends in (allSiblingsReady) without any satellite involvement.
+func (r *SnapshotReconciler) backfillAdoptedSnapshot(
+ ctx context.Context, logger logr.Logger, snap *blockstoriov1alpha1.Snapshot,
+) (ctrl.Result, error) {
+ if allNodesReady(snap.Status.NodeStatus, snap.Spec.Nodes) {
+ return ctrl.Result{}, nil
+ }
+
+ createdAt := adoptedCreatedAt(logger, snap)
+
+ key := client.ObjectKeyFromObject(snap)
+
+ err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
+ var current blockstoriov1alpha1.Snapshot
+
+ getErr := r.Get(ctx, key, ¤t)
+ if getErr != nil {
+ if apierrors.IsNotFound(getErr) {
+ return nil
+ }
+
+ return errors.Wrap(getErr, "get Snapshot for adopted backfill")
+ }
+
+ changed := false
+
+ for _, node := range current.Spec.Nodes {
+ if hasNodeStatusEntry(current.Status.NodeStatus, node) {
+ continue
+ }
+
+ current.Status.NodeStatus = append(current.Status.NodeStatus,
+ blockstoriov1alpha1.SnapshotPerNodeStatus{
+ NodeName: node,
+ Ready: true,
+ CreateTimestamp: createdAt,
+ })
+ changed = true
+ }
+
+ if !changed {
+ return nil
+ }
+
+ return r.Status().Update(ctx, ¤t)
+ })
+ if err != nil {
+ return ctrl.Result{}, errors.Wrap(err, "backfill adopted Snapshot status")
+ }
+
+ logger.V(1).Info("backfilled adopted snapshot per-node status", "nodes", len(snap.Spec.Nodes))
+
+ return ctrl.Result{}, nil
+}
+
+// hasNodeStatusEntry reports whether a per-node status row for the
+// given node already exists (regardless of its Ready value — an
+// existing row belongs to the satellite, never overwrite it).
+func hasNodeStatusEntry(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, node string) bool {
+ for i := range entries {
+ if entries[i].NodeName == node {
+ return true
+ }
+ }
+
+ return false
+}
+
+// adoptedCreatedAt parses the optional ms-epoch original-creation-time
+// annotation of an adopted Snapshot; 0 (and a log line) when absent or
+// unparseable.
+func adoptedCreatedAt(logger logr.Logger, snap *blockstoriov1alpha1.Snapshot) int64 {
+ raw := snap.Annotations[blockstoriov1alpha1.AnnotationSnapshotAdoptedCreatedAt]
+ if raw == "" {
+ return 0
+ }
+
+ parsed, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ logger.Info("ignoring unparseable adopted-created-at annotation", "value", raw)
+
+ return 0
+ }
+
+ return parsed
+}
+
// advancePhase commits the Spec-flag transition nextPhase decided is
// due. Split out of Reconcile (which already carries the get / abort /
// degenerate-guard preamble) to keep each under the funlen budget and
diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go
new file mode 100644
index 00000000..5e734d71
--- /dev/null
+++ b/pkg/linstormigrate/convert.go
@@ -0,0 +1,1086 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
+ k8sstore "github.com/cozystack/blockstor/pkg/store/k8s"
+)
+
+// drbdSharedSecretProp is the RD property key blockstor's satellite
+// renders into the `net {}` section as `shared-secret` (see
+// pkg/rest/drbd_passphrase.go). The converter carries the per-RD DRBD
+// authentication secret from LAYER_DRBD_RESOURCE_DEFINITIONS here so an
+// adopted resource renders the same net config the LINSTOR cluster ran.
+//
+//nolint:gosec // this is a property-name constant, not the secret value itself
+const drbdSharedSecretProp = "DrbdOptions/Net/shared-secret"
+
+// nodeTypeController is the LINSTOR node type blockstor never adopts —
+// blockstor runs its own control plane.
+const nodeTypeController = "CONTROLLER"
+
+// annotationTrue is the canonical truthy annotation value.
+const annotationTrue = "true"
+
+// NODES.node_type integer values. Calibrated against a production dump
+// whose every node reported type 2 while `linstor n l` showed them all
+// as Satellite; 1/3/4 follow LINSTOR's documented
+// CONTROLLER/COMBINED/AUXILIARY ordering around it.
+const (
+ nodeTypeIntController int32 = 1
+ nodeTypeIntSatellite int32 = 2
+ nodeTypeIntCombined int32 = 3
+ nodeTypeIntAuxiliary int32 = 4
+)
+
+// nodeTypeName maps the NODES.node_type integer to the LINSTOR node
+// type string blockstor's Node CRD enum accepts ("" when unknown).
+func nodeTypeName(nodeType int32) string {
+ switch nodeType {
+ case nodeTypeIntController:
+ return nodeTypeController
+ case nodeTypeIntSatellite:
+ return "SATELLITE"
+ case nodeTypeIntCombined:
+ return "COMBINED"
+ case nodeTypeIntAuxiliary:
+ return "AUXILIARY"
+ default:
+ return ""
+ }
+}
+
+// Result is the converted blockstor object set plus the migration
+// report (warnings about rows the converter skipped or bits it could
+// not represent).
+type Result struct {
+ // ControllerConfig is the cluster-wide DRBD option singleton
+ // distilled from the LINSTOR /CTRL property bag; nil when the
+ // source cluster carries no DrbdOptions/* controller props.
+ ControllerConfig *crdv1alpha1.ControllerConfig
+ Nodes []crdv1alpha1.Node
+ StoragePools []crdv1alpha1.StoragePool
+ ResourceGroups []crdv1alpha1.ResourceGroup
+ ResourceDefinitions []crdv1alpha1.ResourceDefinition
+ Resources []crdv1alpha1.Resource
+ Snapshots []crdv1alpha1.Snapshot
+ Warnings []string
+}
+
+// Options tunes a Convert run.
+type Options struct {
+ // DRBDPorts maps a ResourceDefinition name (any case — matched
+ // case-insensitively) to the TCP port its DRBD replication mesh
+ // is CURRENTLY listening on in the running kernel. LINSTOR 1.33
+ // stores port=-1 in its database for most RDs (the port is
+ // assigned at runtime and not persisted), so the dump alone
+ // cannot recover it. Capturing the live ports and feeding them
+ // here makes blockstor render the SAME ports it adopts, so
+ // `drbdadm adjust` is a no-op on the connection endpoint — no
+ // reconnect blip, no transient quorum loss. When a port is
+ // absent here and also absent from the dump, the RD's replicas
+ // get nil DRBDPort and blockstor's allocator picks a fresh port
+ // (a controlled replication blip on adoption — see the runbook).
+ DRBDPorts map[string]int32
+}
+
+// converter carries the indexes built once per Convert call.
+type converter struct {
+ dump *Dump
+ opts Options
+ props *PropsIndex
+
+ // display-name lookups: UPPERCASE key → original display case.
+ nodeDsp map[string]string
+ poolDsp map[string]string
+ rgDsp map[string]string
+ rdDsp map[string]string
+
+ // DRBD layer joins (non-snapshot, base suffix "" only).
+ drbdRD map[string]LayerDrbdResourceDefinitionRow // by resource_name
+ drbdMinor map[volumeKey]*int32 // by (rd, vlmNr)
+ drbdNode map[replicaKey]*int32 // node-id by (node, rd)
+ storVol map[volumeReplicaKey]LayerStorageVolumeRow
+ luksVol map[volumeReplicaKey]LayerLuksVolumeRow
+
+ // referential-integrity sets, populated as each kind converts so
+ // later kinds can drop rows that would dangle (a replica of a
+ // skipped/absent RD, a replica on an unknown node, ...).
+ convertedRD map[string]bool // by resource_name (UPPERCASE key)
+ convertedNode map[string]bool // by node_name (UPPERCASE key)
+ convertedRG map[string]bool // by resource_group_name (UPPERCASE key)
+
+ warnings []string
+ warnedKey map[string]bool
+}
+
+type volumeKey struct {
+ rd string
+ vlmNr int32
+}
+
+type replicaKey struct {
+ node string
+ rd string
+}
+
+type volumeReplicaKey struct {
+ node string
+ rd string
+ vlmNr int32
+}
+
+// Convert translates a LINSTOR database dump into blockstor CRDs with
+// default options.
+func Convert(dump *Dump) (*Result, error) {
+ return ConvertWithOptions(dump, Options{})
+}
+
+// ConvertWithOptions translates a LINSTOR database dump into blockstor
+// CRDs, honouring opts (e.g. a live DRBD port map — see Options).
+func ConvertWithOptions(dump *Dump, opts Options) (*Result, error) {
+ conv := &converter{
+ dump: dump,
+ opts: opts,
+ props: NewPropsIndex(dump.PropsContainers),
+ nodeDsp: map[string]string{},
+ poolDsp: map[string]string{},
+ rgDsp: map[string]string{},
+ rdDsp: map[string]string{},
+ drbdRD: map[string]LayerDrbdResourceDefinitionRow{},
+ drbdMinor: map[volumeKey]*int32{},
+ drbdNode: map[replicaKey]*int32{},
+ storVol: map[volumeReplicaKey]LayerStorageVolumeRow{},
+ luksVol: map[volumeReplicaKey]LayerLuksVolumeRow{},
+ convertedRD: map[string]bool{},
+ convertedNode: map[string]bool{},
+ convertedRG: map[string]bool{},
+ warnedKey: map[string]bool{},
+ }
+
+ conv.buildIndexes()
+
+ res := &Result{
+ ControllerConfig: conv.convertControllerConfig(),
+ Nodes: conv.convertNodes(),
+ StoragePools: conv.convertStoragePools(),
+ ResourceGroups: conv.convertResourceGroups(),
+ ResourceDefinitions: conv.convertResourceDefinitions(),
+ Resources: conv.convertResources(),
+ Snapshots: conv.convertSnapshots(),
+ }
+
+ conv.reportRemotes()
+
+ res.Warnings = conv.warnings
+
+ return res, nil
+}
+
+// convertControllerConfig distills the LINSTOR /CTRL property bag into
+// the blockstor ControllerConfig singleton. Only `DrbdOptions/*` keys
+// carry over — they set cluster-wide DRBD behaviour (auto tiebreaker,
+// verify-alg allow-list, ...) exactly like `linstor controller
+// set-property` would through blockstor's REST. Everything else under
+// /CTRL is LINSTOR runtime plumbing (NetCom connector ports, TLS
+// keystores, Cluster/LocalID, the master-passphrase crypto material)
+// that must NOT leak into blockstor.
+func (c *converter) convertControllerConfig() *crdv1alpha1.ControllerConfig {
+ drbdProps := map[string]string{}
+
+ for key, val := range c.props.Controller {
+ if strings.HasPrefix(key, "DrbdOptions/") {
+ drbdProps[key] = val
+ }
+ }
+
+ if len(drbdProps) == 0 {
+ return nil
+ }
+
+ typed, _, extra := k8sstore.SplitProps(drbdProps)
+
+ return &crdv1alpha1.ControllerConfig{
+ TypeMeta: typeMeta("ControllerConfig"),
+ ObjectMeta: metav1.ObjectMeta{Name: crdv1alpha1.ControllerConfigName},
+ Spec: crdv1alpha1.ControllerConfigSpec{
+ DRBDOptions: typed,
+ ExtraProps: extra,
+ },
+ }
+}
+
+// reportRemotes surfaces operator-created backup-shipping remotes,
+// which have no blockstor equivalent. The self-referential
+// `local-remote-generated-by-linstor` entry every LINSTOR ≥1.21
+// creates automatically is noise and is skipped silently.
+func (c *converter) reportRemotes() {
+ for i := range c.dump.LinstorRemotes {
+ remote := &c.dump.LinstorRemotes[i]
+ if strings.HasSuffix(remote.Name, "-GENERATED-BY-LINSTOR") {
+ continue
+ }
+
+ c.warnf("linstor remote %s (%s): backup-shipping remotes have no blockstor equivalent — not migrated",
+ displayName(remote.DspName, remote.Name), remote.URL)
+ }
+}
+
+func (c *converter) buildIndexes() {
+ c.buildNameIndexes()
+ c.buildLayerIndexes()
+}
+
+func (c *converter) buildNameIndexes() {
+ for i := range c.dump.Nodes {
+ row := &c.dump.Nodes[i]
+ c.nodeDsp[row.NodeName] = displayName(row.NodeDspName, row.NodeName)
+ }
+
+ for i := range c.dump.StorPoolDefinitions {
+ row := &c.dump.StorPoolDefinitions[i]
+ c.poolDsp[row.PoolName] = displayName(row.PoolDspName, row.PoolName)
+ }
+
+ for i := range c.dump.ResourceGroups {
+ row := &c.dump.ResourceGroups[i]
+ c.rgDsp[row.ResourceGroupName] = displayName(row.ResourceGroupDspName, row.ResourceGroupName)
+ }
+
+ for i := range c.dump.ResourceDefinitions {
+ row := &c.dump.ResourceDefinitions[i]
+ if row.SnapshotName == "" {
+ c.rdDsp[row.ResourceName] = displayName(row.ResourceDspName, row.ResourceName)
+ }
+ }
+}
+
+func (c *converter) buildLayerIndexes() {
+ for i := range c.dump.LayerDrbdResourceDefinitions {
+ row := &c.dump.LayerDrbdResourceDefinitions[i]
+ if row.SnapshotName != "" {
+ continue
+ }
+
+ if row.ResourceNameSuffix != "" {
+ // Suffixed layer instances carry external-metadata /
+ // cache sub-devices; neither production dump exercises
+ // them and blockstor has no equivalent yet. Never drop
+ // one silently.
+ c.warnf("resource definition %s: DRBD layer suffix %q not migrated", row.ResourceName, row.ResourceNameSuffix)
+
+ continue
+ }
+
+ c.drbdRD[row.ResourceName] = *row
+ }
+
+ for i := range c.dump.LayerDrbdVolumeDefinitions {
+ row := &c.dump.LayerDrbdVolumeDefinitions[i]
+ if row.SnapshotName == "" && row.ResourceNameSuffix == "" && row.VlmMinorNr != nil {
+ c.drbdMinor[volumeKey{rd: row.ResourceName, vlmNr: row.VlmNr}] = ptr(*row.VlmMinorNr)
+ }
+ }
+
+ c.buildLayerInstanceIndexes()
+}
+
+func (c *converter) buildLayerInstanceIndexes() {
+ // LAYER_RESOURCE_IDS assigns each (node, resource, layer) instance
+ // the integer id the per-layer tables key on. baseLayer filters to
+ // the live (non-snapshot, base-suffix) instances the converter maps.
+ layerByID := map[int32]LayerResourceIDRow{}
+
+ for i := range c.dump.LayerResourceIDs {
+ row := &c.dump.LayerResourceIDs[i]
+ layerByID[row.LayerResourceID] = *row
+ }
+
+ baseLayer := func(id int32) (LayerResourceIDRow, bool) {
+ lri, ok := layerByID[id]
+ if !ok || lri.SnapshotName != "" || lri.LayerResourceSuffix != "" {
+ return LayerResourceIDRow{}, false
+ }
+
+ return lri, true
+ }
+
+ for i := range c.dump.LayerDrbdResources {
+ row := &c.dump.LayerDrbdResources[i]
+ if lri, ok := baseLayer(row.LayerResourceID); ok {
+ c.drbdNode[replicaKey{node: lri.NodeName, rd: lri.ResourceName}] = ptr(row.NodeID)
+ }
+ }
+
+ for i := range c.dump.LayerStorageVolumes {
+ row := &c.dump.LayerStorageVolumes[i]
+ if lri, ok := baseLayer(row.LayerResourceID); ok {
+ c.storVol[volumeReplicaKey{node: lri.NodeName, rd: lri.ResourceName, vlmNr: row.VlmNr}] = *row
+ }
+ }
+
+ for i := range c.dump.LayerLuksVolumes {
+ row := &c.dump.LayerLuksVolumes[i]
+ if lri, ok := baseLayer(row.LayerResourceID); ok {
+ c.luksVol[volumeReplicaKey{node: lri.NodeName, rd: lri.ResourceName, vlmNr: row.VlmNr}] = *row
+ }
+ }
+}
+
+func (c *converter) convertNodes() []crdv1alpha1.Node {
+ nodes := make([]crdv1alpha1.Node, 0, len(c.dump.Nodes))
+
+ for _, row := range c.dump.Nodes {
+ typeName := nodeTypeName(row.NodeType)
+ if typeName == "" {
+ c.warnf("node %s: unknown node_type %d — skipped", row.NodeDspName, row.NodeType)
+
+ continue
+ }
+
+ if typeName == nodeTypeController {
+ c.warnf("node %s: CONTROLLER node not migrated (blockstor runs its own control plane)", row.NodeDspName)
+
+ continue
+ }
+
+ dsp := displayName(row.NodeDspName, row.NodeName)
+
+ // NODES.node_flags carries DELETE / EVICTED markers. The bit
+ // values are not calibrated against a live cluster (no dump
+ // observed a non-zero value), so decoding them would be a guess
+ // — report instead, consistent with every other kind's
+ // never-guess handling, so an operator sees a node the source
+ // cluster had marked for removal or eviction.
+ if row.NodeFlags != 0 {
+ c.warnf("node %s: non-zero node_flags %d (DELETE/EVICTED markers are not decoded) — node migrated as-is, verify it should be adopted", dsp, row.NodeFlags)
+ }
+
+ node := crdv1alpha1.Node{
+ TypeMeta: typeMeta("Node"),
+ ObjectMeta: objectMeta(dsp),
+ Spec: crdv1alpha1.NodeSpec{
+ Type: typeName,
+ Props: c.props.Node(row.NodeName),
+ },
+ }
+
+ for _, nic := range c.netInterfacesFor(row.NodeName) {
+ node.Spec.NetInterfaces = append(node.Spec.NetInterfaces, crdv1alpha1.NodeNetInterface{
+ Name: displayName(nic.NodeNetDspName, nic.NodeNetName),
+ Address: nic.InetAddress,
+ SatellitePort: orZero(nic.StltConnPort),
+ SatelliteEncryptionType: nic.StltConnEncrType,
+ })
+ }
+
+ c.convertedNode[row.NodeName] = true
+
+ nodes = append(nodes, node)
+ }
+
+ sortByName(nodes, func(n crdv1alpha1.Node) string { return n.Name })
+
+ return nodes
+}
+
+func (c *converter) netInterfacesFor(nodeName string) []NodeNetInterfaceRow {
+ var out []NodeNetInterfaceRow
+
+ for _, nic := range c.dump.NodeNetInterfaces {
+ if nic.NodeName == nodeName {
+ out = append(out, nic)
+ }
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].NodeNetName < out[j].NodeNetName })
+
+ return out
+}
+
+func (c *converter) convertStoragePools() []crdv1alpha1.StoragePool {
+ pools := make([]crdv1alpha1.StoragePool, 0, len(c.dump.NodeStorPools))
+
+ for i := range c.dump.NodeStorPools {
+ row := &c.dump.NodeStorPools[i]
+ nodeDsp := c.displayNode(row.NodeName)
+ poolDsp := displayName(c.poolDsp[row.PoolName], row.PoolName)
+
+ // A pool on a node that did not convert (e.g. a CONTROLLER-only
+ // node blockstor never adopts) is dead config — drop it.
+ if !c.convertedNode[row.NodeName] {
+ c.warnf("storage pool %s.%s: host node was not migrated — skipped", poolDsp, nodeDsp)
+
+ continue
+ }
+
+ pool := crdv1alpha1.StoragePool{
+ TypeMeta: typeMeta("StoragePool"),
+ ObjectMeta: objectMeta(strings.ToLower(poolDsp) + "." + strings.ToLower(nodeDsp)),
+ Spec: crdv1alpha1.StoragePoolSpec{
+ NodeName: nodeDsp,
+ PoolName: poolDsp,
+ ProviderKind: row.DriverName,
+ Props: c.props.StorPool(row.NodeName, row.PoolName),
+ },
+ }
+
+ pools = append(pools, pool)
+ }
+
+ sortByName(pools, func(p crdv1alpha1.StoragePool) string { return p.Name })
+
+ return pools
+}
+
+func (c *converter) convertResourceGroups() []crdv1alpha1.ResourceGroup {
+ groups := make([]crdv1alpha1.ResourceGroup, 0, len(c.dump.ResourceGroups))
+
+ for i := range c.dump.ResourceGroups {
+ row := &c.dump.ResourceGroups[i]
+ dsp := displayName(row.ResourceGroupDspName, row.ResourceGroupName)
+
+ typed, residual, extra := k8sstore.SplitProps(c.props.ResourceGroup(row.ResourceGroupName))
+
+ group := crdv1alpha1.ResourceGroup{
+ TypeMeta: typeMeta("ResourceGroup"),
+ ObjectMeta: objectMeta(dsp),
+ Spec: crdv1alpha1.ResourceGroupSpec{
+ Description: row.Description,
+ Props: residual,
+ DRBDOptions: typed,
+ ExtraProps: extra,
+ SelectFilter: crdv1alpha1.ResourceGroupSelectFilter{
+ PlaceCount: row.ReplicaCount,
+ StoragePoolList: c.parseList(row.PoolName, "resource group "+dsp+" pool_name"),
+ StoragePoolDisklessList: c.parseList(row.PoolNameDiskless, "resource group "+dsp+" pool_name_diskless"),
+ NodeNameList: c.parseList(row.NodeNameList, "resource group "+dsp+" node_name_list"),
+ ReplicasOnSame: c.parseList(row.ReplicasOnSame, "resource group "+dsp+" replicas_on_same"),
+ ReplicasOnDifferent: c.parseList(row.ReplicasOnDifferent, "resource group "+dsp+" replicas_on_different"),
+ NotPlaceWithRsc: c.parseList(row.DoNotPlaceWithRsc, "resource group "+dsp+" do_not_place_with_rsc_list"),
+ ProviderList: c.parseList(row.AllowedProviderList, "resource group "+dsp+" allowed_provider_list"),
+ LayerStack: c.parseList(row.LayerStack, "resource group "+dsp+" layer_stack"),
+ },
+ },
+ }
+
+ for _, vg := range c.volumeGroupsFor(row.ResourceGroupName) {
+ group.Spec.VolumeGroups = append(group.Spec.VolumeGroups, crdv1alpha1.ResourceGroupVolumeGroup{
+ VolumeNumber: vg.VlmNr,
+ })
+
+ if vg.Flags != 0 {
+ c.warnf("resource group %s volume group %d: unhandled flags bitmask %d dropped", dsp, vg.VlmNr, vg.Flags)
+ }
+ }
+
+ c.convertedRG[row.ResourceGroupName] = true
+
+ groups = append(groups, group)
+ }
+
+ sortByName(groups, func(g crdv1alpha1.ResourceGroup) string { return g.Name })
+
+ return groups
+}
+
+func (c *converter) volumeGroupsFor(rgName string) []VolumeGroupRow {
+ var out []VolumeGroupRow
+
+ for _, vg := range c.dump.VolumeGroups {
+ if vg.ResourceGroupName == rgName {
+ out = append(out, vg)
+ }
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].VlmNr < out[j].VlmNr })
+
+ return out
+}
+
+func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinition {
+ defs := make([]crdv1alpha1.ResourceDefinition, 0, len(c.dump.ResourceDefinitions))
+
+ for i := range c.dump.ResourceDefinitions {
+ row := &c.dump.ResourceDefinitions[i]
+ if row.SnapshotName != "" {
+ continue // snapshot definitions convert via convertSnapshots
+ }
+
+ dsp := displayName(row.ResourceDspName, row.ResourceName)
+
+ if row.ResourceFlags&resourceFlagDelete != 0 {
+ c.warnf("resource definition %s: marked DELETE in the source cluster — skipped", dsp)
+
+ continue
+ }
+
+ if row.ResourceFlags != 0 {
+ c.warnf("resource definition %s: unhandled flags bitmask %d dropped", dsp, row.ResourceFlags)
+ }
+
+ // Referential integrity: an RD may name a resource group that was
+ // not migrated (an incomplete dump, or an RG the source cluster
+ // deleted). blockstor spawns from an RG but does not require it to
+ // exist for an already-materialised RD, so keep the RD (dropping
+ // it would lose a real volume) but clear the dangling reference
+ // and report it rather than emit an RD pointing at nothing.
+ rgRef := c.displayRG(row.ResourceGroupName)
+ if row.ResourceGroupName != "" && !c.convertedRG[row.ResourceGroupName] {
+ c.warnf("resource definition %s: resource group %q was not migrated — reference cleared", dsp, rgRef)
+
+ rgRef = ""
+ }
+
+ typed, residual, extra := k8sstore.SplitProps(c.props.ResourceDefinition(row.ResourceName))
+
+ def := crdv1alpha1.ResourceDefinition{
+ TypeMeta: typeMeta("ResourceDefinition"),
+ ObjectMeta: objectMeta(dsp),
+ Spec: crdv1alpha1.ResourceDefinitionSpec{
+ ResourceGroupName: rgRef,
+ Props: residual,
+ DRBDOptions: typed,
+ ExtraProps: extra,
+ LayerStack: c.parseList(row.LayerStack, "resource definition "+dsp+" layer_stack"),
+ // The volumes already hold committed data in the source
+ // cluster: latch Initialized so any replica added AFTER
+ // the migration must SyncTarget the real data instead of
+ // skipping the initial sync against an adopted set.
+ Initialized: ptr(true),
+ },
+ }
+
+ c.attachRDLayerFields(&def, row, dsp)
+
+ c.convertedRD[row.ResourceName] = true
+
+ defs = append(defs, def)
+ }
+
+ sortByName(defs, func(d crdv1alpha1.ResourceDefinition) string { return d.Name })
+
+ return defs
+}
+
+// attachRDLayerFields fills the DRBD (port, shared-secret), volume and
+// LUKS-report bits onto a converted ResourceDefinition.
+func (c *converter) attachRDLayerFields(def *crdv1alpha1.ResourceDefinition, row *ResourceDefinitionRow, dsp string) {
+ if drbd, ok := c.drbdRD[row.ResourceName]; ok {
+ def.Spec.DRBDPort = c.resolveDRBDPort(row.ResourceName, drbd.TCPPort, dsp)
+
+ if drbd.Secret != "" {
+ if def.Spec.ExtraProps == nil {
+ def.Spec.ExtraProps = map[string]string{}
+ }
+ // Carried so the satellite renders the same `net {
+ // shared-secret }` the source cluster's kernels already
+ // run — adoption then re-applies an identical config
+ // instead of re-keying the mesh.
+ def.Spec.ExtraProps[drbdSharedSecretProp] = drbd.Secret
+ }
+ }
+
+ def.Spec.VolumeDefinitions = c.volumeDefinitionsFor(row.ResourceName, dsp)
+
+ if c.rdHasLuks(row.ResourceName) {
+ // LAYER_LUKS_VOLUMES carries the volume passphrase encrypted
+ // with the LINSTOR master key; decrypting it needs the
+ // operator's master passphrase and LINSTOR's KDF, which this
+ // converter does not implement yet. The layer stack is
+ // preserved, but the volume cannot be opened until the
+ // passphrase is provisioned into blockstor by hand.
+ c.warnf("resource definition %s: LUKS passphrase NOT migrated (encrypted with the LINSTOR master key) — provision spec.encryption manually before adopting", dsp)
+ }
+}
+
+// rdHasLuks reports whether any live replica of the RD carries a LUKS
+// layer volume (an encrypted passphrase row).
+func (c *converter) rdHasLuks(rdName string) bool {
+ for key := range c.luksVol {
+ if key.rd == rdName {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (c *converter) volumeDefinitionsFor(rdName, rdDsp string) []crdv1alpha1.ResourceDefinitionVolume {
+ var out []crdv1alpha1.ResourceDefinitionVolume
+
+ for _, vd := range c.dump.VolumeDefinitions {
+ if vd.ResourceName != rdName || vd.SnapshotName != "" {
+ continue
+ }
+
+ if vd.VlmFlags&resourceFlagDelete != 0 {
+ c.warnf("volume definition %s/%d: marked DELETE in the source cluster — skipped", rdDsp, vd.VlmNr)
+
+ continue
+ }
+
+ if vd.VlmFlags != 0 {
+ c.warnf("volume definition %s/%d: unhandled flags bitmask %d dropped", rdDsp, vd.VlmNr, vd.VlmFlags)
+ }
+
+ out = append(out, crdv1alpha1.ResourceDefinitionVolume{
+ VolumeNumber: vd.VlmNr,
+ SizeKib: vd.VlmSize,
+ Props: c.props.VolumeDefinition(rdName, strconv.Itoa(int(vd.VlmNr))),
+ DRBDMinor: c.drbdMinor[volumeKey{rd: rdName, vlmNr: vd.VlmNr}],
+ })
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].VolumeNumber < out[j].VolumeNumber })
+
+ return out
+}
+
+func (c *converter) convertResources() []crdv1alpha1.Resource {
+ resources := make([]crdv1alpha1.Resource, 0, len(c.dump.Resources))
+
+ for i := range c.dump.Resources {
+ row := &c.dump.Resources[i]
+ if row.SnapshotName != "" {
+ continue // snapshot placement rows feed convertSnapshots
+ }
+
+ rdDsp := c.displayRD(row.ResourceName)
+ nodeDsp := c.displayNode(row.NodeName)
+ replicaName := rdDsp + "." + nodeDsp
+
+ if row.ResourceFlags&resourceFlagDelete != 0 {
+ c.warnf("resource %s: marked DELETE in the source cluster — skipped", replicaName)
+
+ continue
+ }
+
+ // Referential integrity: never emit a replica whose parent RD
+ // or host node did not convert. Such a Resource would dangle
+ // (the CRD's . name references an object that is not
+ // applied), so drop it loudly rather than adopt an orphan.
+ if !c.convertedRD[row.ResourceName] {
+ c.warnf("resource %s: parent resource definition was not migrated — replica skipped", replicaName)
+
+ continue
+ }
+
+ if !c.convertedNode[row.NodeName] {
+ c.warnf("resource %s: host node %s was not migrated — replica skipped", replicaName, nodeDsp)
+
+ continue
+ }
+
+ // blockstor's Resource carries ONE storage pool for the whole
+ // replica, while LINSTOR allows a per-volume pool. Collapsing a
+ // divergent set to volume 0's pool would send blockstor looking
+ // for the other volumes' backing devices in the wrong pool — a
+ // fresh empty zvol next to the real data. Never guess: report
+ // and skip the replica.
+ if pools, divergent := c.replicaPoolDivergence(row); divergent {
+ c.warnf("resource %s: volumes span multiple storage pools (%s) but blockstor holds one pool per replica — replica skipped", replicaName, strings.Join(pools, ", "))
+
+ continue
+ }
+
+ resources = append(resources, c.buildResource(row, rdDsp, nodeDsp, replicaName))
+ }
+
+ sortByName(resources, func(r crdv1alpha1.Resource) string { return r.Name })
+
+ return resources
+}
+
+// reportReplicaVolumeFlags surfaces non-zero VOLUMES.vlm_flags on this
+// replica's per-volume rows. blockstor's Resource carries no per-volume
+// flag field, so nothing is decoded; the bits (DELETE, RESIZE, and
+// friends) are reported so a volume the source cluster had marked is
+// never adopted silently.
+func (c *converter) reportReplicaVolumeFlags(row *ResourceRow, replicaName string) {
+ for i := range c.dump.Volumes {
+ vol := &c.dump.Volumes[i]
+ if vol.NodeName != row.NodeName || vol.ResourceName != row.ResourceName ||
+ vol.SnapshotName != "" || vol.VlmFlags == 0 {
+ continue
+ }
+
+ c.warnf("resource %s volume %d: non-zero vlm_flags %d (not decoded; blockstor has no per-volume flag field) — volume adopted as-is",
+ replicaName, vol.VlmNr, vol.VlmFlags)
+ }
+}
+
+// replicaPoolDivergence reports whether this replica's volumes are
+// backed by more than one storage pool (LINSTOR allows a per-volume
+// pool; blockstor's Resource holds a single one). Returns the distinct
+// pool names, sorted, and whether they diverge. A replica with no
+// LAYER_STORAGE_VOLUMES rows at all (diskless) never diverges.
+func (c *converter) replicaPoolDivergence(row *ResourceRow) ([]string, bool) {
+ seen := map[string]bool{}
+
+ for key, storVol := range c.storVol {
+ if key.node == row.NodeName && key.rd == row.ResourceName {
+ seen[displayName(c.poolDsp[storVol.StorPoolName], storVol.StorPoolName)] = true
+ }
+ }
+
+ if len(seen) < 2 {
+ return nil, false
+ }
+
+ pools := make([]string, 0, len(seen))
+ for pool := range seen {
+ pools = append(pools, pool)
+ }
+
+ sort.Strings(pools)
+
+ return pools, true
+}
+
+// buildResource assembles one replica CRD from its RESOURCES row (the
+// caller has already applied the DELETE / referential-integrity
+// skips).
+func (c *converter) buildResource(row *ResourceRow, rdDsp, nodeDsp, replicaName string) crdv1alpha1.Resource {
+ flags, rest := decodeResourceFlags(row.ResourceFlags)
+ if rest != 0 {
+ c.warnf("resource %s: unhandled flags bits %d dropped (of %d)", replicaName, rest, row.ResourceFlags)
+ }
+
+ c.reportReplicaVolumeFlags(row, replicaName)
+
+ typed, residual, extra := k8sstore.SplitProps(c.props.Resource(row.NodeName, row.ResourceName))
+
+ resource := crdv1alpha1.Resource{
+ TypeMeta: typeMeta("Resource"),
+ ObjectMeta: objectMeta(replicaName),
+ Spec: crdv1alpha1.ResourceSpec{
+ ResourceDefinitionName: rdDsp,
+ NodeName: nodeDsp,
+ Props: residual,
+ DRBDOptions: typed,
+ ExtraProps: extra,
+ Flags: flags,
+ DRBDNodeID: c.drbdNode[replicaKey{node: row.NodeName, rd: row.ResourceName}],
+ // Adopted replicas carry real data (or are the witness of
+ // a data-bearing set): never allow the day0 skip.
+ SkipInitialSync: ptr(false),
+ },
+ }
+
+ if storVol, ok := c.storVol[volumeReplicaKey{node: row.NodeName, rd: row.ResourceName, vlmNr: 0}]; ok {
+ resource.Spec.StoragePool = displayName(c.poolDsp[storVol.StorPoolName], storVol.StorPoolName)
+ } else if sp := resource.Spec.Props["StorPoolName"]; sp != "" {
+ resource.Spec.StoragePool = sp
+ }
+
+ if drbd, ok := c.drbdRD[row.ResourceName]; ok {
+ // LINSTOR allocates one cluster-wide TCP port per RD; every
+ // replica listens on it. blockstor's per-replica allocator
+ // honours a preset value verbatim. The live port map (when
+ // supplied) wins over the dump's often-empty tcp_port so the
+ // adopted mesh keeps its current endpoint.
+ resource.Spec.DRBDPort = c.resolveDRBDPort(row.ResourceName, drbd.TCPPort, rdDsp)
+ }
+
+ return resource
+}
+
+func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot {
+ var snaps []crdv1alpha1.Snapshot
+
+ for i := range c.dump.ResourceDefinitions {
+ row := &c.dump.ResourceDefinitions[i]
+ if row.SnapshotName == "" {
+ continue
+ }
+
+ rdDsp := c.displayRD(row.ResourceName)
+ snapDsp := displayName(row.SnapshotDspName, row.SnapshotName)
+ name := rdDsp + "." + snapDsp
+
+ switch {
+ case row.ResourceFlags&snapDfnFlagFailedDeployment != 0:
+ c.warnf("snapshot %s: FAILED_DEPLOYMENT in the source cluster — skipped", name)
+
+ continue
+ case row.ResourceFlags&snapDfnFlagSuccessful == 0:
+ c.warnf("snapshot %s: not marked SUCCESSFUL (flags %d, take in flight?) — skipped", name, row.ResourceFlags)
+
+ continue
+ case !c.convertedRD[row.ResourceName]:
+ c.warnf("snapshot %s: parent resource definition was not migrated — skipped", name)
+
+ continue
+ }
+
+ nodes := c.snapshotNodesFor(row.ResourceName, row.SnapshotName, snapDsp)
+ if len(nodes) == 0 {
+ c.warnf("snapshot %s: no placement on a migrated node — skipped", name)
+
+ continue
+ }
+
+ snaps = append(snaps, c.buildSnapshot(row, rdDsp, snapDsp, name, nodes))
+ }
+
+ sortByName(snaps, func(s crdv1alpha1.Snapshot) string { return s.Name })
+
+ return snaps
+}
+
+// buildSnapshot assembles one adopted Snapshot CRD (the caller has
+// applied the FAILED/SUCCESSFUL/parent/nodes skips).
+func (c *converter) buildSnapshot(row *ResourceDefinitionRow, rdDsp, snapDsp, name string, nodes []string) crdv1alpha1.Snapshot {
+ snap := crdv1alpha1.Snapshot{
+ TypeMeta: typeMeta("Snapshot"),
+ ObjectMeta: objectMeta(name),
+ Spec: crdv1alpha1.SnapshotSpec{
+ ResourceDefinitionName: rdDsp,
+ SnapshotName: snapDsp,
+ Props: c.props.SnapshotDefinition(row.ResourceName, row.SnapshotName),
+ Nodes: nodes,
+ },
+ }
+
+ // The on-disk snapshot ALREADY EXISTS on every listed node — mark the
+ // CRD adopted so the controller backfills the terminal Ready state
+ // instead of running the suspend→take→resume orchestration (which
+ // would freeze production I/O to re-take an existing snapshot). The
+ // original creation time rides along for `linstor s l` parity.
+ snap.Annotations = map[string]string{crdv1alpha1.AnnotationSnapshotAdopted: annotationTrue}
+
+ if ts := c.snapshotCreatedAtFor(row.ResourceName, row.SnapshotName); ts > 0 {
+ snap.Annotations[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt] = strconv.FormatInt(ts, 10)
+ }
+
+ snap.Spec.VolumeDefinitions = c.snapshotVolumeRefsFor(row.ResourceName, row.SnapshotName)
+
+ // blockstor's ZFS provider addresses a snapshot as
+ // `/_00000@` — volume 0 only (multi-volume
+ // snapshot support is not implemented). A snapshot that captured
+ // more than one volume therefore adopts with only its first volume
+ // reachable for restore/delete; report it rather than let the extra
+ // volume slots imply coverage that does not exist.
+ if len(snap.Spec.VolumeDefinitions) > 1 {
+ c.warnf("snapshot %s: captured %d volumes but blockstor addresses only volume 0 (/_00000@) — restore/delete of the other volumes will not find a dataset",
+ name, len(snap.Spec.VolumeDefinitions))
+ }
+
+ return snap
+}
+
+// resolveDRBDPort picks the DRBD listen port to preset on an RD /
+// replica. The live port map (Options.DRBDPorts, captured from the
+// running kernel) wins over the dump's tcp_port, because LINSTOR 1.33
+// leaves tcp_port empty for most RDs while the mesh really is
+// listening on a concrete port — matching it makes adoption's
+// `drbdadm adjust` a no-op on the connection endpoint. When the port
+// is available in NEITHER source the RD's replicas get nil (blockstor
+// allocates a fresh port → a controlled reconnect blip on adoption),
+// and the gap is reported so the operator can capture the live ports.
+func (c *converter) resolveDRBDPort(rdName string, dumpPort *int32, rdDsp string) *int32 {
+ if live, ok := c.opts.DRBDPorts[strings.ToLower(rdName)]; ok {
+ return ptr(live)
+ }
+
+ if dumpPort != nil && *dumpPort > 0 {
+ return dumpPort
+ }
+
+ c.warnOncef("drbdport:"+rdName,
+ "resource definition %s: no DRBD port in the dump or live map — blockstor will allocate a fresh port; the adopted mesh will reconnect on the new port. Capture live ports (see runbook) to avoid the blip.", rdDsp)
+
+ return nil
+}
+
+// snapshotVolumeRefsFor collects the snapshot's captured volume slots
+// (number + size) from the snapshot-scoped VOLUME_DEFINITIONS rows.
+func (c *converter) snapshotVolumeRefsFor(rdName, snapName string) []crdv1alpha1.SnapshotVolumeRef {
+ var out []crdv1alpha1.SnapshotVolumeRef
+
+ for i := range c.dump.VolumeDefinitions {
+ vd := &c.dump.VolumeDefinitions[i]
+ if vd.ResourceName != rdName || vd.SnapshotName != snapName {
+ continue
+ }
+
+ out = append(out, crdv1alpha1.SnapshotVolumeRef{
+ VolumeNumber: vd.VlmNr,
+ SizeKib: vd.VlmSize,
+ })
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].VolumeNumber < out[j].VolumeNumber })
+
+ return out
+}
+
+// snapshotCreatedAtFor returns the newest create_timestamp (ms epoch)
+// across the snapshot's per-node RESOURCES rows — the instant the take
+// completed cluster-wide; 0 when the rows carry no timestamp.
+func (c *converter) snapshotCreatedAtFor(rdName, snapName string) int64 {
+ var newest int64
+
+ for i := range c.dump.Resources {
+ row := &c.dump.Resources[i]
+ if row.ResourceName == rdName && row.SnapshotName == snapName && row.CreateTimestamp > newest {
+ newest = row.CreateTimestamp
+ }
+ }
+
+ return newest
+}
+
+// snapshotNodesFor lists the nodes an adopted Snapshot targets, dropping
+// any placement on a node that did not convert (CONTROLLER / unknown /
+// skipped). Keeping such a node would make the adopted Snapshot wait for
+// terminal readiness from a Node object that never exists. snapName is
+// used for the per-exclusion warning.
+func (c *converter) snapshotNodesFor(rdName, snapName, snapDsp string) []string {
+ var out []string
+
+ for i := range c.dump.Resources {
+ row := &c.dump.Resources[i]
+ if row.ResourceName != rdName || row.SnapshotName != snapName {
+ continue
+ }
+
+ if !c.convertedNode[row.NodeName] {
+ c.warnf("snapshot %s.%s: placement on un-migrated node %s dropped", c.displayRD(rdName), snapDsp, c.displayNode(row.NodeName))
+
+ continue
+ }
+
+ out = append(out, c.displayNode(row.NodeName))
+ }
+
+ sort.Strings(out)
+
+ return out
+}
+
+// parseList decodes LINSTOR's JSON-array-in-a-string columns
+// (`"[\"DRBD\",\"STORAGE\"]"`). Empty input, `[]` and `null` all yield
+// nil; a malformed value is reported and yields nil.
+func (c *converter) parseList(raw, context string) []string {
+ if raw == "" || raw == "[]" || raw == "null" {
+ return nil
+ }
+
+ var out []string
+
+ err := json.Unmarshal([]byte(raw), &out)
+ if err != nil {
+ c.warnf("%s: unparsable list %q dropped: %v", context, raw, err)
+
+ return nil
+ }
+
+ if len(out) == 0 {
+ return nil
+ }
+
+ return out
+}
+
+func (c *converter) displayNode(name string) string {
+ return displayName(c.nodeDsp[name], name)
+}
+
+func (c *converter) displayRD(name string) string {
+ return displayName(c.rdDsp[name], name)
+}
+
+func (c *converter) displayRG(name string) string {
+ if name == "" {
+ return ""
+ }
+
+ return displayName(c.rgDsp[name], name)
+}
+
+func (c *converter) warnf(format string, args ...any) {
+ c.warnings = append(c.warnings, fmt.Sprintf(format, args...))
+}
+
+// warnOncef emits a warning only the first time it sees dedupKey, so a
+// per-RD gap reported from multiple call sites (RD + each replica)
+// surfaces once instead of once per object.
+func (c *converter) warnOncef(dedupKey, format string, args ...any) {
+ if c.warnedKey[dedupKey] {
+ return
+ }
+
+ c.warnedKey[dedupKey] = true
+ c.warnf(format, args...)
+}
+
+// displayName prefers the display-case column, falling back to the
+// UPPERCASE key column when the display column is empty.
+func displayName(dsp, key string) string {
+ if dsp != "" {
+ return dsp
+ }
+
+ return key
+}
+
+func typeMeta(kind string) metav1.TypeMeta {
+ return metav1.TypeMeta{
+ APIVersion: crdv1alpha1.GroupVersion.String(),
+ Kind: kind,
+ }
+}
+
+// objectMeta builds the CRD metadata for a LINSTOR object name:
+// metadata.name via the store's normalization (lowercase, slugged when
+// not RFC-1123-clean) and the original spelling preserved in the
+// blockstor.io/linstor-name annotation when it differs.
+func objectMeta(original string) metav1.ObjectMeta {
+ meta := metav1.ObjectMeta{Name: k8sstore.Name(original)}
+ k8sstore.SetOriginalName(&meta, original)
+
+ return meta
+}
+
+func sortByName[T any](items []T, name func(T) string) {
+ sort.Slice(items, func(i, j int) bool { return name(items[i]) < name(items[j]) })
+}
+
+func ptr[T any](v T) *T {
+ return &v
+}
+
+func orZero(v *int32) int32 {
+ if v == nil {
+ return 0
+ }
+
+ return *v
+}
diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go
new file mode 100644
index 00000000..056ffe9a
--- /dev/null
+++ b/pkg/linstormigrate/convert_test.go
@@ -0,0 +1,810 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "bytes"
+ "flag"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
+ "github.com/cozystack/blockstor/pkg/satellite"
+ "github.com/cozystack/blockstor/pkg/storage"
+ k8sstore "github.com/cozystack/blockstor/pkg/store/k8s"
+)
+
+// The fixture under testdata/dump is a synthetic LINSTOR k8s-backend
+// dump. Its shape (tables, column sets, JSON-array-in-string columns,
+// layer-id joins, props instance paths, flag bitmask populations) was
+// modelled on two production cluster dumps; every identifier, IP,
+// DRBD shared-secret and LUKS ciphertext in it is fabricated.
+//
+// Cases covered:
+// - vol1: DRBD,STORAGE ×2 diskful + a TIE_BREAKER witness (flags 388)
+// whose diskless replica has NO LAYER_STORAGE_VOLUMES row (LINSTOR
+// writes none for diskless) — the pool falls back to the replica's
+// StorPoolName prop; a STALE TIE_BREAKER bit (flags 128) on a
+// diskful replica that must be dropped, mirroring auto-diskful
+// leftovers observed in production; a preset TCP port + per-volume
+// minor + per-replica node-ids; a SUCCESSFUL and a
+// FAILED_DEPLOYMENT snapshot (only the former migrates).
+// - vol2: STORAGE-only single-replica volume.
+// - vol3: DRBD,LUKS,STORAGE with LAYER_LUKS_VOLUMES ciphertexts.
+// - vol4: RD marked DELETE — skipped entirely.
+// - vol5: allow-two-primaries (RWX) prop + an unknown flag bit 2048
+// replica that must convert with the bit reported, not guessed.
+
+//nolint:gochecknoglobals // standard Go golden-test update flag
+var update = flag.Bool("update", false, "rewrite the golden files under testdata/golden")
+
+func convertFixture(t *testing.T) *Result {
+ t.Helper()
+
+ dump, err := LoadDump(filepath.Join("testdata", "dump"))
+ if err != nil {
+ t.Fatalf("LoadDump: %v", err)
+ }
+
+ res, err := Convert(dump)
+ if err != nil {
+ t.Fatalf("Convert: %v", err)
+ }
+
+ return res
+}
+
+// TestConvertGolden pins the full manifest stream and report so ANY
+// behaviour change in the converter surfaces as a reviewable diff.
+func TestConvertGolden(t *testing.T) {
+ res := convertFixture(t)
+
+ var manifests, report bytes.Buffer
+
+ err := WriteManifests(&manifests, res)
+ if err != nil {
+ t.Fatalf("WriteManifests: %v", err)
+ }
+
+ err = WriteReport(&report, res)
+ if err != nil {
+ t.Fatalf("WriteReport: %v", err)
+ }
+
+ compareGolden(t, filepath.Join("testdata", "golden", "manifests.yaml"), manifests.Bytes())
+ compareGolden(t, filepath.Join("testdata", "golden", "report.txt"), report.Bytes())
+}
+
+func compareGolden(t *testing.T, path string, got []byte) {
+ t.Helper()
+
+ if *update {
+ err := os.WriteFile(path, got, 0o600)
+ if err != nil {
+ t.Fatalf("update golden %s: %v", path, err)
+ }
+
+ return
+ }
+
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read golden %s (run `go test ./pkg/linstormigrate -run TestConvertGolden -update` to create): %v", path, err)
+ }
+
+ if !bytes.Equal(want, got) {
+ t.Errorf("%s differs from golden output; re-run with -update and review the diff", path)
+ }
+}
+
+func findResource(t *testing.T, res *Result, name string) *crdv1alpha1.Resource {
+ t.Helper()
+
+ for i := range res.Resources {
+ if res.Resources[i].Name == name {
+ return &res.Resources[i]
+ }
+ }
+
+ t.Fatalf("converted output has no Resource %q", name)
+
+ return nil
+}
+
+// TestTieBreakerFlagsRequireDiskless pins the flag-decode calibration:
+// the diskless witness keeps TIE_BREAKER while the STALE TIE_BREAKER
+// bit on a diskful replica (an auto-diskful leftover observed in
+// production) is dropped and reported. Written against the initial
+// implementation that resolved diskless-ness from LAYER_STORAGE_VOLUMES
+// — which has no rows for diskless replicas, so the witness LOST its
+// TIE_BREAKER flag (this test fails on that code).
+func TestTieBreakerFlagsRequireDiskless(t *testing.T) {
+ res := convertFixture(t)
+
+ witness := findResource(t, res, "pvc-vol1.node-c")
+ if !slices.Equal(witness.Spec.Flags, []string{"DISKLESS", "DRBD_DISKLESS", "TIE_BREAKER"}) {
+ t.Errorf("witness flags = %v, want [DISKLESS DRBD_DISKLESS TIE_BREAKER]", witness.Spec.Flags)
+ }
+
+ if witness.Spec.StoragePool != "DfltDisklessStorPool" {
+ t.Errorf("witness storagePool = %q, want DfltDisklessStorPool (StorPoolName prop fallback)", witness.Spec.StoragePool)
+ }
+
+ stale := findResource(t, res, "pvc-vol1.node-b")
+ if len(stale.Spec.Flags) != 0 {
+ t.Errorf("diskful replica with stale TIE_BREAKER bit converted flags = %v, want none", stale.Spec.Flags)
+ }
+
+ if !hasWarning(res, "pvc-vol1.node-b: unhandled flags bits 128") {
+ t.Errorf("stale TIE_BREAKER bit drop was not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestAdoptionSafetyLatches pins the two fields that make adopting
+// LINSTOR volumes data-safe: every RD arrives Initialized and every
+// replica arrives with the day0 skip disabled.
+func TestAdoptionSafetyLatches(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.ResourceDefinitions {
+ rd := &res.ResourceDefinitions[i]
+ if rd.Spec.Initialized == nil || !*rd.Spec.Initialized {
+ t.Errorf("RD %s: Initialized not latched true", rd.Name)
+ }
+ }
+
+ for i := range res.Resources {
+ replica := &res.Resources[i]
+ if replica.Spec.SkipInitialSync == nil || *replica.Spec.SkipInitialSync {
+ t.Errorf("Resource %s: skipInitialSync must be pinned false", replica.Name)
+ }
+ }
+}
+
+// TestDRBDIdentityCarriedVerbatim pins minors / node-ids / the RD TCP
+// port — the identities that must survive migration byte-for-byte or
+// the adopted kernel state no longer matches the CRDs.
+func TestDRBDIdentityCarriedVerbatim(t *testing.T) {
+ res := convertFixture(t)
+
+ var vol1 *crdv1alpha1.ResourceDefinition
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol1" {
+ vol1 = &res.ResourceDefinitions[i]
+
+ break
+ }
+ }
+
+ if vol1 == nil {
+ t.Fatal("pvc-vol1 not converted")
+ }
+
+ if vol1.Spec.DRBDPort == nil || *vol1.Spec.DRBDPort != 7001 {
+ t.Errorf("pvc-vol1 drbdPort = %v, want 7001", vol1.Spec.DRBDPort)
+ }
+
+ if len(vol1.Spec.VolumeDefinitions) == 0 || vol1.Spec.VolumeDefinitions[0].DRBDMinor == nil ||
+ *vol1.Spec.VolumeDefinitions[0].DRBDMinor != 1001 {
+ t.Errorf("pvc-vol1 volume-0 minor not carried: %+v", vol1.Spec.VolumeDefinitions)
+ }
+
+ if vol1.Spec.ExtraProps[drbdSharedSecretProp] != "fixture-secret-vol1" {
+ t.Errorf("pvc-vol1 shared secret not carried into %s", drbdSharedSecretProp)
+ }
+
+ for name, wantID := range map[string]int32{
+ "pvc-vol1.node-a": 0,
+ "pvc-vol1.node-b": 1,
+ "pvc-vol1.node-c": 2,
+ } {
+ replica := findResource(t, res, name)
+ if replica.Spec.DRBDNodeID == nil || *replica.Spec.DRBDNodeID != wantID {
+ t.Errorf("%s drbdNodeID = %v, want %d", name, replica.Spec.DRBDNodeID, wantID)
+ }
+
+ if replica.Spec.DRBDPort == nil || *replica.Spec.DRBDPort != 7001 {
+ t.Errorf("%s drbdPort = %v, want 7001", name, replica.Spec.DRBDPort)
+ }
+ }
+}
+
+// TestZvolNameAdoptionInvariant pins B2: adoption is name-based —
+// blockstor addresses each zvol as `/_`
+// (pkg/storage/zfs/zfs.go volumeDataset) and CreateVolume is idempotent
+// only when that dataset already exists. LINSTOR wrote the on-disk zvols
+// as `/_00000`. So the RD's converted
+// metadata.name MUST equal the lowercased LINSTOR name verbatim — any
+// slug/hash from k8sstore.Name (triggered by a non-RFC-1123 name) would
+// make blockstor look for a dataset that does not exist and create a
+// fresh EMPTY zvol. Every production RD name is a clean `pvc-`
+// (verified across both dumps: 113/113 + 679/679), which lowercases to
+// an RFC-1123-clean name that Name() passes through unchanged. This
+// test pins that verbatim-lowercase invariant for the fixture and for a
+// real uppercase UUID; if a future change made the converter hash a
+// clean name, single-replica STORAGE-only volumes would silently adopt
+// an empty disk.
+func TestZvolNameAdoptionInvariant(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.ResourceDefinitions {
+ rd := &res.ResourceDefinitions[i]
+ // The fixture original is the uppercase LINSTOR key (e.g.
+ // PVC-VOL1); its lowercase form is RFC-1123-clean, so the
+ // converted metadata.name must be exactly that — no hash prefix.
+ original := k8sstore.OriginalName(&rd.ObjectMeta)
+ if original == "" {
+ continue // name needed no annotation → already lowercase-clean
+ }
+
+ want := strings.ToLower(original)
+
+ if rd.Name != want {
+ t.Errorf("RD metadata.name = %q, want verbatim-lowercase %q (a slug/hash breaks zvol adoption)", rd.Name, want)
+ }
+ }
+
+ // A real production-shaped uppercase UUID must pass through as the
+ // plain lowercased name (no hash) so its zvol path is predictable.
+ const prodName = "PVC-6A5F9F68-5DF2-4398-A92F-CDF41C4F2653"
+
+ got := objectMeta(prodName).Name
+ if got != strings.ToLower(prodName) {
+ t.Errorf("k8sstore.Name(%q) = %q, want plain lowercase; a hashed name would miss the on-disk zvol", prodName, got)
+ }
+}
+
+// TestConvertedZFSPoolsRegisterProvider pins B1 end-to-end at the
+// migration layer: a real LINSTOR ZFS pool stores its zpool name under
+// StorDriver/StorPoolName (the fixture mirrors a production ZFS-backed
+// shape — ZFS thick, StorPoolName=data, no ZPool key). The migrator
+// copies props verbatim, so unless the satellite factory accepts that
+// key, every converted ZFS StoragePool fails to register a provider
+// and NO diskful resource can adopt. Running the converted props
+// through the real satellite.NewProviderFromKind proves the whole
+// chain works; it fails on the pre-fix factory (the B1 blocker).
+func TestConvertedZFSPoolsRegisterProvider(t *testing.T) {
+ res := convertFixture(t)
+
+ zfsPools := 0
+
+ for i := range res.StoragePools {
+ pool := &res.StoragePools[i]
+ if !strings.HasPrefix(pool.Spec.ProviderKind, "ZFS") {
+ continue // DISKLESS pools register no provider by design
+ }
+
+ zfsPools++
+
+ prov, err := satellite.NewProviderFromKind(pool.Spec.ProviderKind, pool.Spec.Props, storage.NewFakeExec())
+ if err != nil {
+ t.Errorf("StoragePool %s (kind %s): provider did not register from migrated props %v: %v",
+ pool.Name, pool.Spec.ProviderKind, pool.Spec.Props, err)
+
+ continue
+ }
+
+ if prov == nil {
+ t.Errorf("StoragePool %s: nil provider from migrated props", pool.Name)
+ }
+ }
+
+ if zfsPools == 0 {
+ t.Fatal("fixture has no ZFS StoragePools — the B1 regression is not being exercised")
+ }
+}
+
+// TestLiveDRBDPortWins pins the adoption-critical port behaviour: a
+// live port map (captured from the running kernel) is preset on the RD
+// and every replica, overriding the dump. Without it, an RD whose dump
+// carries no tcp_port (the LINSTOR-1.33 norm) gets nil DRBDPort and a
+// reported gap — because a mismatched port makes adoption's `drbdadm
+// adjust` reconnect the live mesh.
+func TestLiveDRBDPortWins(t *testing.T) {
+ dump, err := LoadDump(filepath.Join("testdata", "dump"))
+ if err != nil {
+ t.Fatalf("LoadDump: %v", err)
+ }
+
+ // vol5 carries NO tcp_port in the fixture; vol1 carries 7001.
+ res, err := ConvertWithOptions(dump, Options{
+ DRBDPorts: map[string]int32{"pvc-vol5": 7042, "pvc-vol1": 7099},
+ })
+ if err != nil {
+ t.Fatalf("Convert: %v", err)
+ }
+
+ ports := map[string]*int32{}
+ for i := range res.ResourceDefinitions {
+ ports[res.ResourceDefinitions[i].Name] = res.ResourceDefinitions[i].Spec.DRBDPort
+ }
+
+ if ports["pvc-vol5"] == nil || *ports["pvc-vol5"] != 7042 {
+ t.Errorf("vol5 (no dump port) DRBDPort = %v, want live 7042", ports["pvc-vol5"])
+ }
+
+ if ports["pvc-vol1"] == nil || *ports["pvc-vol1"] != 7099 {
+ t.Errorf("vol1 DRBDPort = %v, want live 7099 overriding dump 7001", ports["pvc-vol1"])
+ }
+
+ // replicas of vol5 must carry the same live port.
+ replica := findResource(t, res, "pvc-vol5.node-a")
+ if replica.Spec.DRBDPort == nil || *replica.Spec.DRBDPort != 7042 {
+ t.Errorf("vol5.node-a DRBDPort = %v, want 7042", replica.Spec.DRBDPort)
+ }
+}
+
+// TestMissingDRBDPortReported pins that a DRBD RD with no port in the
+// dump AND no live map yields nil (blockstor allocates) and reports
+// the gap exactly once — the operator MUST see it to decide on the
+// reconnect blip.
+func TestMissingDRBDPortReported(t *testing.T) {
+ res := convertFixture(t) // no port map
+
+ var vol5Port *int32
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol5" {
+ vol5Port = res.ResourceDefinitions[i].Spec.DRBDPort
+ }
+ }
+
+ if vol5Port != nil {
+ t.Errorf("vol5 DRBDPort = %v, want nil (no dump port, no live map)", vol5Port)
+ }
+
+ count := 0
+
+ for _, w := range res.Warnings {
+ if strings.Contains(w, "pvc-vol5") && strings.Contains(w, "DRBD port") {
+ count++
+ }
+ }
+
+ if count != 1 {
+ t.Errorf("vol5 missing-port warning count = %d, want exactly 1 (deduped across RD + replicas)", count)
+ }
+}
+
+// TestParseDRBDPorts pins the port-file parser incl. rejection.
+func TestParseDRBDPorts(t *testing.T) {
+ ports, err := ParseDRBDPorts("# comment\nPVC-ABC 7001\n\npvc-def 7002\n")
+ if err != nil {
+ t.Fatalf("ParseDRBDPorts: %v", err)
+ }
+
+ if ports["pvc-abc"] != 7001 || ports["pvc-def"] != 7002 {
+ t.Errorf("parsed ports = %v", ports)
+ }
+
+ for _, bad := range []string{"only-one-field", "rd 70000", "rd notaport", "rd 0"} {
+ if _, err := ParseDRBDPorts(bad); err == nil {
+ t.Errorf("ParseDRBDPorts(%q) accepted a malformed line", bad)
+ }
+ }
+}
+
+// TestSkippedRows pins the never-guess policy: DELETE'd RDs and
+// non-SUCCESSFUL snapshots are dropped with a report line instead of
+// being converted into live objects.
+func TestSkippedRows(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol4" {
+ t.Error("DELETE'd RD pvc-vol4 must not convert")
+ }
+ }
+
+ if !hasWarning(res, "pvc-vol4: marked DELETE") {
+ t.Errorf("DELETE'd RD skip not reported; warnings: %v", res.Warnings)
+ }
+
+ names := make([]string, 0, len(res.Snapshots))
+ for i := range res.Snapshots {
+ names = append(names, res.Snapshots[i].Name)
+ }
+
+ if !slices.Equal(names, []string{"pvc-vol1.snap-good"}) {
+ t.Errorf("snapshots = %v, want only pvc-vol1.snap-good (snap-bad is FAILED_DEPLOYMENT)", names)
+ }
+
+ // The adopted-snapshot annotations keep the controller from
+ // re-running the suspend→take orchestration against production
+ // I/O; the created-at annotation carries the newest per-node
+ // create_timestamp (node-b's 1770000000011 in the fixture).
+ if len(res.Snapshots) == 1 {
+ ann := res.Snapshots[0].Annotations
+ if ann[crdv1alpha1.AnnotationSnapshotAdopted] != "true" {
+ t.Errorf("migrated snapshot missing %s annotation: %v", crdv1alpha1.AnnotationSnapshotAdopted, ann)
+ }
+
+ if ann[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt] != "1770000000011" {
+ t.Errorf("adopted-created-at = %q, want 1770000000011", ann[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt])
+ }
+ }
+
+ if !hasWarning(res, "snap-bad: FAILED_DEPLOYMENT") {
+ t.Errorf("failed snapshot skip not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestMultiVolumeMinorsVerbatim pins the multi-volume case observed in
+// production (two volume slots whose DRBD minors are NOT contiguous):
+// each volume must carry its own minor verbatim — a base+k derivation
+// would corrupt the second volume's device identity.
+func TestMultiVolumeMinorsVerbatim(t *testing.T) {
+ res := convertFixture(t)
+
+ var vol1 *crdv1alpha1.ResourceDefinition
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol1" {
+ vol1 = &res.ResourceDefinitions[i]
+
+ break
+ }
+ }
+
+ if vol1 == nil {
+ t.Fatal("pvc-vol1 not converted")
+ }
+
+ if len(vol1.Spec.VolumeDefinitions) != 2 {
+ t.Fatalf("pvc-vol1 volumeDefinitions = %d, want 2", len(vol1.Spec.VolumeDefinitions))
+ }
+
+ for i, want := range []struct {
+ number int32
+ minor int32
+ size int64
+ }{{0, 1001, 1048576}, {1, 1042, 307200}} {
+ got := vol1.Spec.VolumeDefinitions[i]
+ if got.VolumeNumber != want.number || got.SizeKib != want.size ||
+ got.DRBDMinor == nil || *got.DRBDMinor != want.minor {
+ t.Errorf("volume[%d] = {nr %d, size %d, minor %v}, want {nr %d, size %d, minor %d}",
+ i, got.VolumeNumber, got.SizeKib, got.DRBDMinor, want.number, want.size, want.minor)
+ }
+ }
+}
+
+// TestControllerConfigDistilled pins the /CTRL handling: cluster-wide
+// DrbdOptions/* carry into the ControllerConfig singleton while
+// LINSTOR runtime plumbing (NetCom ports, Cluster/LocalID) and the
+// master-key crypto material MUST NOT leak into blockstor.
+func TestControllerConfigDistilled(t *testing.T) {
+ res := convertFixture(t)
+
+ cc := res.ControllerConfig
+ if cc == nil {
+ t.Fatal("ControllerConfig not emitted despite /CTRL DrbdOptions props")
+ }
+
+ if cc.Name != crdv1alpha1.ControllerConfigName {
+ t.Errorf("ControllerConfig name = %q, want %q", cc.Name, crdv1alpha1.ControllerConfigName)
+ }
+
+ if cc.Spec.ExtraProps["DrbdOptions/auto-add-quorum-tiebreaker"] != "True" {
+ t.Errorf("auto-add-quorum-tiebreaker not carried; extraProps: %v", cc.Spec.ExtraProps)
+ }
+
+ if cc.Spec.ExtraProps["DrbdOptions/auto-verify-algo-allowed-list"] == "" {
+ t.Errorf("auto-verify-algo-allowed-list not carried; extraProps: %v", cc.Spec.ExtraProps)
+ }
+
+ for _, forbidden := range []string{"NetCom/SslConnector/Port", "Cluster/LocalID", "encryptedMasterKey"} {
+ if _, ok := cc.Spec.ExtraProps[forbidden]; ok {
+ t.Errorf("LINSTOR plumbing key %q leaked into ControllerConfig", forbidden)
+ }
+ }
+}
+
+// TestTypedDRBDOptionsRouting pins the props→typed split on the two
+// behaviour-bearing cases from production: replication protocol A on a
+// resource group and dual-primary (RWX) on an RD.
+func TestTypedDRBDOptionsRouting(t *testing.T) {
+ res := convertFixture(t)
+
+ var rg *crdv1alpha1.ResourceGroup
+
+ for i := range res.ResourceGroups {
+ if res.ResourceGroups[i].Name == "sc-replicated" {
+ rg = &res.ResourceGroups[i]
+
+ break
+ }
+ }
+
+ if rg == nil {
+ t.Fatal("sc-replicated not converted")
+ }
+
+ if rg.Spec.DRBDOptions == nil || rg.Spec.DRBDOptions.Net == nil || rg.Spec.DRBDOptions.Net.Protocol != "A" {
+ t.Errorf("sc-replicated protocol not typed: %+v", rg.Spec.DRBDOptions)
+ }
+
+ var vol5 *crdv1alpha1.ResourceDefinition
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol5" {
+ vol5 = &res.ResourceDefinitions[i]
+
+ break
+ }
+ }
+
+ if vol5 == nil {
+ t.Fatal("pvc-vol5 not converted")
+ }
+
+ if vol5.Spec.DRBDOptions == nil || vol5.Spec.DRBDOptions.Net == nil ||
+ vol5.Spec.DRBDOptions.Net.AllowTwoPrimaries == nil || !*vol5.Spec.DRBDOptions.Net.AllowTwoPrimaries {
+ t.Errorf("pvc-vol5 allow-two-primaries not typed: %+v", vol5.Spec.DRBDOptions)
+ }
+}
+
+// TestRemoteWarning pins the backup-shipping posture: the LINSTOR
+// auto-generated self-remote stays silent, an operator-created remote
+// is reported.
+func TestRemoteWarning(t *testing.T) {
+ res := convertFixture(t)
+
+ if !hasWarning(res, "offsite-dr") {
+ t.Errorf("operator-created linstor remote not reported; warnings: %v", res.Warnings)
+ }
+
+ if hasWarning(res, "local-remote-generated-by-linstor") {
+ t.Errorf("auto-generated self-remote must stay silent; warnings: %v", res.Warnings)
+ }
+}
+
+// TestOrphanReplicaSkipped pins referential integrity: vol4's RD is
+// DELETE'd (skipped), so vol4.node-a — a replica whose parent RD did
+// not convert — must NOT be emitted as an orphan Resource (whose
+// . CRD would reference a non-existent RD), and the drop
+// must be reported. Without the guard the replica converts and the
+// server-side CRD apply would leave a dangling Resource.
+func TestOrphanReplicaSkipped(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.Resources {
+ if res.Resources[i].Name == "pvc-vol4.node-a" {
+ t.Error("replica of a DELETE'd RD (pvc-vol4) must not convert into an orphan Resource")
+ }
+
+ if res.Resources[i].Spec.ResourceDefinitionName == "pvc-vol4" {
+ t.Errorf("Resource %s references un-migrated RD pvc-vol4", res.Resources[i].Name)
+ }
+ }
+
+ if !hasWarning(res, "pvc-vol4.node-a: parent resource definition was not migrated") {
+ t.Errorf("orphan-replica skip not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestDivergentPerVolumePoolsSkipped pins the never-guess rule for the
+// one data-bearing field blockstor cannot represent faithfully:
+// LINSTOR allows a per-VOLUME storage pool, blockstor's Resource holds
+// ONE pool per replica. Collapsing a divergent set to volume 0's pool
+// would send blockstor looking for the other volumes' backing devices
+// in the wrong pool — a fresh empty zvol beside the real data (silent
+// for a single-replica STORAGE-only volume). The replica must be
+// skipped and reported instead.
+func TestDivergentPerVolumePoolsSkipped(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.Resources {
+ if res.Resources[i].Name == "pvc-vol7.node-a" {
+ t.Error("replica whose volumes span two storage pools must be skipped, not collapsed to volume 0's pool")
+ }
+ }
+
+ if !hasWarning(res, "pvc-vol7.node-a: volumes span multiple storage pools") {
+ t.Errorf("divergent per-volume pools not reported; warnings: %v", res.Warnings)
+ }
+
+ // A replica whose volumes share one pool still converts.
+ findResource(t, res, "pvc-vol1.node-a")
+}
+
+// TestMultiVolumeSnapshotCoverageReported pins the honest-coverage rule
+// for adopted snapshots: blockstor's ZFS provider addresses a snapshot
+// as `/_00000@` (zfs.go snapshotDataset — volume 0
+// only), so a snapshot that captured several volumes adopts with just
+// its first volume restorable. The extra volume slots must not imply
+// coverage that does not exist on disk.
+func TestMultiVolumeSnapshotCoverageReported(t *testing.T) {
+ res := convertFixture(t)
+
+ var multi bool
+
+ for i := range res.Snapshots {
+ if len(res.Snapshots[i].Spec.VolumeDefinitions) > 1 {
+ multi = true
+ }
+ }
+
+ if !multi {
+ t.Fatal("fixture has no multi-volume snapshot — the coverage warning is not exercised")
+ }
+
+ if !hasWarning(res, "blockstor addresses only volume 0") {
+ t.Errorf("multi-volume snapshot coverage limit not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestVolumeAndNodeFlagsReported pins that the two tables previously
+// loaded-but-never-examined now surface their markers: a non-zero
+// VOLUMES.vlm_flags (DELETE/RESIZE and friends) is reported rather than
+// adopted silently. Bit values are deliberately NOT decoded — that
+// would be a guess — but the operator sees them.
+func TestVolumeAndNodeFlagsReported(t *testing.T) {
+ res := convertFixture(t)
+
+ if !hasWarning(res, "volume 0: non-zero vlm_flags 64") {
+ t.Errorf("non-zero VOLUMES.vlm_flags not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestReferencesResolveWithinConvertedSet pins cross-object referential
+// integrity (the class Gemini flagged): every reference a converted
+// object carries must resolve to another converted object's
+// metadata.name — a `Resource` to its `ResourceDefinition` and `Node`, a
+// `Snapshot` to its `ResourceDefinition`, an `RD` to its `ResourceGroup`
+// (or empty). References use the LINSTOR display name and metadata.name
+// is `k8sstore.Name(display)`; for the clean lowercase names blockstor
+// carries on the wire the two are identical, so a converted reference
+// never dangles or points at a differently-cased object.
+func TestReferencesResolveWithinConvertedSet(t *testing.T) {
+ res := convertFixture(t)
+
+ rdNames := map[string]bool{}
+ for i := range res.ResourceDefinitions {
+ rdNames[res.ResourceDefinitions[i].Name] = true
+ }
+
+ nodeNames := map[string]bool{}
+ for i := range res.Nodes {
+ nodeNames[res.Nodes[i].Name] = true
+ }
+
+ rgNames := map[string]bool{}
+ for i := range res.ResourceGroups {
+ rgNames[res.ResourceGroups[i].Name] = true
+ }
+
+ for i := range res.Resources {
+ r := &res.Resources[i]
+ if !rdNames[k8sstore.Name(r.Spec.ResourceDefinitionName)] {
+ t.Errorf("Resource %s references RD %q with no converted object", r.Name, r.Spec.ResourceDefinitionName)
+ }
+
+ if !nodeNames[k8sstore.Name(r.Spec.NodeName)] {
+ t.Errorf("Resource %s references Node %q with no converted object", r.Name, r.Spec.NodeName)
+ }
+ }
+
+ for i := range res.Snapshots {
+ s := &res.Snapshots[i]
+ if !rdNames[k8sstore.Name(s.Spec.ResourceDefinitionName)] {
+ t.Errorf("Snapshot %s references RD %q with no converted object", s.Name, s.Spec.ResourceDefinitionName)
+ }
+ }
+
+ for i := range res.ResourceDefinitions {
+ rd := &res.ResourceDefinitions[i]
+ if rd.Spec.ResourceGroupName != "" && !rgNames[k8sstore.Name(rd.Spec.ResourceGroupName)] {
+ t.Errorf("RD %s references RG %q with no converted object", rd.Name, rd.Spec.ResourceGroupName)
+ }
+ }
+}
+
+// TestDanglingResourceGroupRefCleared pins the referential-integrity
+// guard for RG references (CodeRabbit finding): an RD whose
+// resource_group_name names a group that did not convert must keep the
+// RD (dropping it would lose a real volume) but clear the dangling
+// reference and report it, so the RD never points at a non-existent RG.
+func TestDanglingResourceGroupRefCleared(t *testing.T) {
+ res := convertFixture(t)
+
+ var vol6 *crdv1alpha1.ResourceDefinition
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol6" {
+ vol6 = &res.ResourceDefinitions[i]
+
+ break
+ }
+ }
+
+ if vol6 == nil {
+ t.Fatal("pvc-vol6 (dangling-RG RD) must still convert, not be dropped")
+ }
+
+ if vol6.Spec.ResourceGroupName != "" {
+ t.Errorf("dangling RG ref = %q, want cleared", vol6.Spec.ResourceGroupName)
+ }
+
+ if !hasWarning(res, "pvc-vol6: resource group") {
+ t.Errorf("dangling RG ref not reported; warnings: %v", res.Warnings)
+ }
+}
+
+// TestSnapshotDropsUnmigratedNodes pins the referential-integrity guard
+// for snapshot placements (CodeRabbit finding): a snapshot node that did
+// not convert is dropped from Spec.Nodes (else the adopted Snapshot
+// waits for Ready from a Node object that never exists). The fixture's
+// snap-good is placed on node-a/node-b (both migrated), so both survive
+// and no diskless/controller node leaks in.
+func TestSnapshotDropsUnmigratedNodes(t *testing.T) {
+ res := convertFixture(t)
+
+ for i := range res.Snapshots {
+ snap := &res.Snapshots[i]
+ for _, node := range snap.Spec.Nodes {
+ if !slices.Contains([]string{"node-a", "node-b", "node-c"}, node) {
+ t.Errorf("snapshot %s targets un-migrated node %q", snap.Name, node)
+ }
+ }
+ }
+}
+
+// TestLuksPassphraseWarning pins the phase-1 LUKS posture: the
+// encrypted volume converts with its layer stack intact, and the
+// non-migratable master-key-encrypted passphrase is loudly reported
+// instead of silently dropped.
+func TestLuksPassphraseWarning(t *testing.T) {
+ res := convertFixture(t)
+
+ var vol3 *crdv1alpha1.ResourceDefinition
+
+ for i := range res.ResourceDefinitions {
+ if res.ResourceDefinitions[i].Name == "pvc-vol3" {
+ vol3 = &res.ResourceDefinitions[i]
+
+ break
+ }
+ }
+
+ if vol3 == nil {
+ t.Fatal("LUKS RD pvc-vol3 not converted")
+ }
+
+ if !slices.Equal(vol3.Spec.LayerStack, []string{"DRBD", "LUKS", "STORAGE"}) {
+ t.Errorf("pvc-vol3 layerStack = %v, want [DRBD LUKS STORAGE]", vol3.Spec.LayerStack)
+ }
+
+ if !hasWarning(res, "pvc-vol3: LUKS passphrase NOT migrated") {
+ t.Errorf("LUKS passphrase warning missing; warnings: %v", res.Warnings)
+ }
+}
+
+func hasWarning(res *Result, substr string) bool {
+ for _, w := range res.Warnings {
+ if strings.Contains(w, substr) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/linstormigrate/dump.go b/pkg/linstormigrate/dump.go
new file mode 100644
index 00000000..bcd2b6f1
--- /dev/null
+++ b/pkg/linstormigrate/dump.go
@@ -0,0 +1,121 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+// dumpFileSuffix is the file-name suffix each table dump carries:
+// `.internal.linstor.linbit.com.json`.
+const dumpFileSuffix = ".internal.linstor.linbit.com.json"
+
+// ErrEmptyDump is returned when the input directory contains none of
+// the expected `*.internal.linstor.linbit.com.json` table files.
+var ErrEmptyDump = errors.New("no LINSTOR table dumps found")
+
+// listEnvelope is the kubectl `-ojson` List wrapper. Each item's spec
+// is one SQL row; everything else (metadata, apiVersion) is transport.
+type listEnvelope struct {
+ Items []struct {
+ Spec json.RawMessage `json:"spec"`
+ } `json:"items"`
+}
+
+// loadTable reads `/.internal.linstor.linbit.com.json` and
+// decodes every item's spec into out (a pointer to a slice of row
+// structs). A missing file is not an error — older LINSTOR schemas
+// lack some tables and empty tables may be omitted from a dump.
+func loadTable[T any](dir, table string, out *[]T) error {
+ path := filepath.Join(dir, table+dumpFileSuffix)
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+
+ return fmt.Errorf("read %s: %w", path, err)
+ }
+
+ var list listEnvelope
+
+ err = json.Unmarshal(data, &list)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", path, err)
+ }
+
+ rows := make([]T, 0, len(list.Items))
+
+ for i, item := range list.Items {
+ var row T
+
+ err = json.Unmarshal(item.Spec, &row)
+ if err != nil {
+ return fmt.Errorf("parse %s item %d spec: %w", path, i, err)
+ }
+
+ rows = append(rows, row)
+ }
+
+ *out = rows
+
+ return nil
+}
+
+// LoadDump reads every table the converter consumes from dir.
+func LoadDump(dir string) (*Dump, error) {
+ dump := &Dump{}
+
+ err := errors.Join(
+ loadTable(dir, "nodes", &dump.Nodes),
+ loadTable(dir, "nodenetinterfaces", &dump.NodeNetInterfaces),
+ loadTable(dir, "nodestorpool", &dump.NodeStorPools),
+ loadTable(dir, "storpooldefinitions", &dump.StorPoolDefinitions),
+ loadTable(dir, "resourcegroups", &dump.ResourceGroups),
+ loadTable(dir, "volumegroups", &dump.VolumeGroups),
+ loadTable(dir, "resourcedefinitions", &dump.ResourceDefinitions),
+ loadTable(dir, "volumedefinitions", &dump.VolumeDefinitions),
+ loadTable(dir, "resources", &dump.Resources),
+ loadTable(dir, "volumes", &dump.Volumes),
+ loadTable(dir, "propscontainers", &dump.PropsContainers),
+ loadTable(dir, "layerresourceids", &dump.LayerResourceIDs),
+ loadTable(dir, "layerdrbdresourcedefinitions", &dump.LayerDrbdResourceDefinitions),
+ loadTable(dir, "layerdrbdresources", &dump.LayerDrbdResources),
+ loadTable(dir, "layerdrbdvolumedefinitions", &dump.LayerDrbdVolumeDefinitions),
+ loadTable(dir, "layerdrbdvolumes", &dump.LayerDrbdVolumes),
+ loadTable(dir, "layerstoragevolumes", &dump.LayerStorageVolumes),
+ loadTable(dir, "layerluksvolumes", &dump.LayerLuksVolumes),
+ loadTable(dir, "linstorremotes", &dump.LinstorRemotes),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if len(dump.Nodes) == 0 && len(dump.ResourceDefinitions) == 0 {
+ return nil, fmt.Errorf("%w in %s (expected *%s files)", ErrEmptyDump, dir, dumpFileSuffix)
+ }
+
+ return dump, nil
+}
diff --git a/pkg/linstormigrate/emit.go b/pkg/linstormigrate/emit.go
new file mode 100644
index 00000000..1c063df8
--- /dev/null
+++ b/pkg/linstormigrate/emit.go
@@ -0,0 +1,133 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "sigs.k8s.io/yaml"
+)
+
+// WriteManifests renders the converted object set as one multi-document
+// YAML stream in apply order (nodes → pools → groups → definitions →
+// resources → snapshots) with a stable sort inside each kind, so the
+// output is deterministic and diffable between runs.
+func WriteManifests(w io.Writer, res *Result) error {
+ docs := make([]any, 0,
+ 1+len(res.Nodes)+len(res.StoragePools)+len(res.ResourceGroups)+
+ len(res.ResourceDefinitions)+len(res.Resources)+len(res.Snapshots))
+
+ if res.ControllerConfig != nil {
+ docs = append(docs, res.ControllerConfig)
+ }
+
+ for i := range res.Nodes {
+ docs = append(docs, &res.Nodes[i])
+ }
+
+ for i := range res.StoragePools {
+ docs = append(docs, &res.StoragePools[i])
+ }
+
+ for i := range res.ResourceGroups {
+ docs = append(docs, &res.ResourceGroups[i])
+ }
+
+ for i := range res.ResourceDefinitions {
+ docs = append(docs, &res.ResourceDefinitions[i])
+ }
+
+ for i := range res.Resources {
+ docs = append(docs, &res.Resources[i])
+ }
+
+ for i := range res.Snapshots {
+ docs = append(docs, &res.Snapshots[i])
+ }
+
+ for _, doc := range docs {
+ data, err := marshalManifest(doc)
+ if err != nil {
+ return err
+ }
+
+ _, err = fmt.Fprintf(w, "---\n%s", data)
+ if err != nil {
+ return fmt.Errorf("write manifest: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// marshalManifest renders one CRD as YAML, pruning the noise a direct
+// struct marshal would leak into manifests meant for `kubectl apply`:
+// the zero `metadata.creationTimestamp: null` and the empty `status`.
+func marshalManifest(obj any) ([]byte, error) {
+ jsonBytes, err := json.Marshal(obj)
+ if err != nil {
+ return nil, fmt.Errorf("marshal manifest: %w", err)
+ }
+
+ var tree map[string]any
+
+ err = json.Unmarshal(jsonBytes, &tree)
+ if err != nil {
+ return nil, fmt.Errorf("reparse manifest: %w", err)
+ }
+
+ delete(tree, "status")
+
+ if meta, ok := tree["metadata"].(map[string]any); ok {
+ if ts, present := meta["creationTimestamp"]; present && ts == nil {
+ delete(meta, "creationTimestamp")
+ }
+ }
+
+ data, err := yaml.Marshal(tree)
+ if err != nil {
+ return nil, fmt.Errorf("render manifest yaml: %w", err)
+ }
+
+ return data, nil
+}
+
+// WriteReport renders the migration warnings, one per line, prefixed so
+// they read cleanly on stderr next to the manifest stream on stdout.
+func WriteReport(w io.Writer, res *Result) error {
+ var buf bytes.Buffer
+
+ fmt.Fprintf(&buf, "converted: %d nodes, %d storage pools, %d resource groups, %d resource definitions, %d resources, %d snapshots\n",
+ len(res.Nodes), len(res.StoragePools), len(res.ResourceGroups),
+ len(res.ResourceDefinitions), len(res.Resources), len(res.Snapshots))
+
+ for _, warning := range res.Warnings {
+ fmt.Fprintf(&buf, "warning: %s\n", warning)
+ }
+
+ _, err := w.Write(buf.Bytes())
+ if err != nil {
+ return fmt.Errorf("write report: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/linstormigrate/flags.go b/pkg/linstormigrate/flags.go
new file mode 100644
index 00000000..740313a1
--- /dev/null
+++ b/pkg/linstormigrate/flags.go
@@ -0,0 +1,113 @@
+// 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.
+*/
+
+package linstormigrate
+
+// LINSTOR stores object flags in its database as integer bitmasks; the
+// REST API (and blockstor) speak flag STRINGS. The bit values below were
+// calibrated EMPIRICALLY by cross-referencing production database dumps
+// against the same cluster's `linstor --machine-readable r l` / `s l`
+// output (observed bit sets ↔ reported flag strings), NOT by reading the
+// LINSTOR source:
+//
+// - resource_flags 388 = 0b110000100 on a replica the REST API reports
+// as DISKLESS,DRBD_DISKLESS,TIE_BREAKER;
+// - resource_flags 268 = 0b100001100 on plain diskless replicas
+// (DISKLESS,DRBD_DISKLESS without TIE_BREAKER) — isolating bit 128
+// as TIE_BREAKER and bit 256 as DRBD_DISKLESS;
+// - a snapshot definition with resource_flags=2 that `linstor s l`
+// reports as FAILED_DEPLOYMENT, and the complementary population of
+// flags=1 (successfully deployed snapshots) pinning SUCCESSFUL=1.
+//
+// Bits observed in production dumps that map to no REST-visible flag
+// (e.g. 128 on a DISKFUL replica after an auto-diskful toggle, 8 on
+// diskless replicas, 64/2048/262144) are runtime bookkeeping — the
+// converter reports them and deliberately does NOT emit flag strings
+// for them.
+const (
+ // resourceFlagDelete marks an object under deletion (bit 1<<1,
+ // shared by resources, resource definitions, volumes and snapshot
+ // definitions in LINSTOR's schema; for snapshot definitions the
+ // same bit doubles as FAILED_DEPLOYMENT — see snapshot handling).
+ resourceFlagDelete int64 = 1 << 1
+
+ // resourceFlagDiskless is the generic diskless bit (1<<2).
+ resourceFlagDiskless int64 = 1 << 2
+
+ // resourceFlagTieBreaker marks an auto-placed quorum witness
+ // (1<<7). Only meaningful on a replica that is also diskless —
+ // production dumps show stale TIE_BREAKER bits left behind on
+ // replicas that auto-diskful later converted to diskful, which
+ // the REST API filters out.
+ resourceFlagTieBreaker int64 = 1 << 7
+
+ // resourceFlagDrbdDiskless (1<<8) is the DRBD-layer diskless bit
+ // that accompanies resourceFlagDiskless on DRBD-backed replicas.
+ resourceFlagDrbdDiskless int64 = 1 << 8
+
+ // snapDfnFlagSuccessful (1<<0) marks a snapshot definition whose
+ // deployment completed on every node.
+ snapDfnFlagSuccessful int64 = 1 << 0
+
+ // snapDfnFlagFailedDeployment (1<<1) marks a snapshot definition
+ // whose take failed — calibrated against a live `linstor s l`
+ // showing FAILED_DEPLOYMENT for a dump row with resource_flags=2.
+ snapDfnFlagFailedDeployment int64 = 1 << 1
+)
+
+// blockstor flag string vocabulary (matches pkg/rest/flags_validation.go
+// and upstream golinstor apiconsts).
+const (
+ flagStrDiskless = "DISKLESS"
+ flagStrDrbdDiskless = "DRBD_DISKLESS"
+ flagStrTieBreaker = "TIE_BREAKER"
+)
+
+// decodeResourceFlags translates a RESOURCES-row bitmask into blockstor
+// flag strings. The TIE_BREAKER bit is honoured only when the DISKLESS
+// bit is also set: production dumps show stale TIE_BREAKER bits left on
+// replicas that auto-diskful converted to diskful (which the REST API
+// filters out the same way). Note LINSTOR writes no LAYER_STORAGE_VOLUMES
+// row for diskless replicas, so the replica's own flag bits — not the
+// storage layer — are the authoritative diskless signal.
+// The second return carries the bits the converter dropped (recognised
+// but unrepresentable, or unknown) for the migration report; 0 when
+// everything mapped.
+func decodeResourceFlags(flags int64) ([]string, int64) {
+ var out []string
+
+ rest := flags
+ diskless := flags&resourceFlagDiskless != 0
+
+ if diskless {
+ out = append(out, flagStrDiskless)
+ rest &^= resourceFlagDiskless
+ }
+
+ if flags&resourceFlagDrbdDiskless != 0 {
+ out = append(out, flagStrDrbdDiskless)
+ rest &^= resourceFlagDrbdDiskless
+ }
+
+ if flags&resourceFlagTieBreaker != 0 && diskless {
+ out = append(out, flagStrTieBreaker)
+ rest &^= resourceFlagTieBreaker
+ }
+
+ return out, rest
+}
diff --git a/pkg/linstormigrate/model.go b/pkg/linstormigrate/model.go
new file mode 100644
index 00000000..6ecfe97e
--- /dev/null
+++ b/pkg/linstormigrate/model.go
@@ -0,0 +1,262 @@
+// 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.
+*/
+
+// Package linstormigrate converts a LINSTOR k8s-backend database dump
+// (the `*.internal.linstor.linbit.com` CRDs LINSTOR uses as its SQL
+// tables when running with the k8s connector) into blockstor
+// `blockstor.cozystack.io/v1alpha1` CRD manifests.
+//
+// The input is the directory produced by:
+//
+// kubectl get crds | grep -o ".*.internal.linstor.linbit.com" | \
+// xargs -I{} sh -c "kubectl get {} -ojson > {}.json"
+//
+// Each file is a v1.List whose items carry one SQL row in `spec` —
+// the schema mirrors LINSTOR's relational tables (RESOURCE_DEFINITIONS,
+// VOLUME_DEFINITIONS, LAYER_DRBD_RESOURCES, PROPS_CONTAINERS, ...).
+package linstormigrate
+
+// NodeRow is one row of the NODES table.
+type NodeRow struct {
+ NodeName string `json:"node_name"` // uppercase key
+ NodeDspName string `json:"node_dsp_name"` // display case
+ NodeFlags int64 `json:"node_flags"`
+ NodeType int32 `json:"node_type"`
+ UUID string `json:"uuid"`
+}
+
+// NodeNetInterfaceRow is one row of the NODE_NET_INTERFACES table.
+type NodeNetInterfaceRow struct {
+ NodeName string `json:"node_name"`
+ NodeNetName string `json:"node_net_name"`
+ NodeNetDspName string `json:"node_net_dsp_name"`
+ InetAddress string `json:"inet_address"`
+ StltConnPort *int32 `json:"stlt_conn_port,omitempty"`
+ StltConnEncrType string `json:"stlt_conn_encr_type,omitempty"`
+ UUID string `json:"uuid"`
+}
+
+// NodeStorPoolRow is one row of the NODE_STOR_POOL table.
+type NodeStorPoolRow struct {
+ NodeName string `json:"node_name"`
+ PoolName string `json:"pool_name"`
+ DriverName string `json:"driver_name"`
+ FreeSpaceMgrName string `json:"free_space_mgr_name,omitempty"`
+ FreeSpaceMgrDspName string `json:"free_space_mgr_dsp_name,omitempty"`
+ ExternalLocking bool `json:"external_locking,omitempty"`
+ UUID string `json:"uuid"`
+}
+
+// StorPoolDefinitionRow is one row of the STOR_POOL_DEFINITIONS table.
+type StorPoolDefinitionRow struct {
+ PoolName string `json:"pool_name"`
+ PoolDspName string `json:"pool_dsp_name"`
+ UUID string `json:"uuid"`
+}
+
+// ResourceGroupRow is one row of the RESOURCE_GROUPS table. The list
+// fields are JSON-encoded arrays stored as strings (`"[\"data\"]"`).
+type ResourceGroupRow struct {
+ ResourceGroupName string `json:"resource_group_name"`
+ ResourceGroupDspName string `json:"resource_group_dsp_name"`
+ Description string `json:"description,omitempty"`
+ LayerStack string `json:"layer_stack,omitempty"`
+ ReplicaCount int32 `json:"replica_count,omitempty"`
+ NodeNameList string `json:"node_name_list,omitempty"`
+ PoolName string `json:"pool_name,omitempty"`
+ PoolNameDiskless string `json:"pool_name_diskless,omitempty"`
+ DoNotPlaceWithRsc string `json:"do_not_place_with_rsc_list,omitempty"`
+ ReplicasOnSame string `json:"replicas_on_same,omitempty"`
+ ReplicasOnDifferent string `json:"replicas_on_different,omitempty"`
+ AllowedProviderList string `json:"allowed_provider_list,omitempty"`
+ UUID string `json:"uuid"`
+}
+
+// VolumeGroupRow is one row of the VOLUME_GROUPS table.
+type VolumeGroupRow struct {
+ ResourceGroupName string `json:"resource_group_name"`
+ VlmNr int32 `json:"vlm_nr"`
+ Flags int64 `json:"flags"`
+ UUID string `json:"uuid"`
+}
+
+// ResourceDefinitionRow is one row of the RESOURCE_DEFINITIONS table.
+// Rows with SnapshotName != "" are snapshot definitions, not RDs.
+type ResourceDefinitionRow struct {
+ ResourceName string `json:"resource_name"`
+ ResourceDspName string `json:"resource_dsp_name"`
+ ResourceGroupName string `json:"resource_group_name,omitempty"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ SnapshotDspName string `json:"snapshot_dsp_name,omitempty"`
+ ResourceFlags int64 `json:"resource_flags"`
+ LayerStack string `json:"layer_stack,omitempty"`
+ ParentUUID string `json:"parent_uuid,omitempty"`
+ UUID string `json:"uuid"`
+}
+
+// VolumeDefinitionRow is one row of the VOLUME_DEFINITIONS table.
+// Rows with SnapshotName != "" belong to a snapshot definition.
+type VolumeDefinitionRow struct {
+ ResourceName string `json:"resource_name"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ VlmNr int32 `json:"vlm_nr"`
+ VlmSize int64 `json:"vlm_size"` // KiB
+ VlmFlags int64 `json:"vlm_flags"`
+ UUID string `json:"uuid"`
+}
+
+// ResourceRow is one row of the RESOURCES table (one replica placement).
+// Rows with SnapshotName != "" record a snapshot's presence on a node.
+type ResourceRow struct {
+ NodeName string `json:"node_name"`
+ ResourceName string `json:"resource_name"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ ResourceFlags int64 `json:"resource_flags"`
+ CreateTimestamp int64 `json:"create_timestamp,omitempty"` // ms epoch
+ UUID string `json:"uuid"`
+}
+
+// VolumeRow is one row of the VOLUMES table.
+type VolumeRow struct {
+ NodeName string `json:"node_name"`
+ ResourceName string `json:"resource_name"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ VlmNr int32 `json:"vlm_nr"`
+ VlmFlags int64 `json:"vlm_flags"`
+ UUID string `json:"uuid"`
+}
+
+// PropsContainerRow is one row of the PROPS_CONTAINERS table — a single
+// (instance, key) = value property. Instance paths look like
+// `/RSC_DFNS/`, `/RSCS//`, `/VLM_DFNS//`,
+// `/SNAPS///`, `/NODES/`, `/RSC_GRPS/`,
+// `/STOR_POOLS//`? (observed shapes vary), `/CTRL`.
+type PropsContainerRow struct {
+ PropsInstance string `json:"props_instance"`
+ PropKey string `json:"prop_key"`
+ PropValue string `json:"prop_value"`
+}
+
+// LayerResourceIDRow is one row of LAYER_RESOURCE_IDS — the join table
+// that assigns every (node, resource[, snapshot]) layer instance an
+// integer id the per-layer tables reference. ParentID links a child
+// layer (STORAGE) to its parent (DRBD/LUKS) in the same stack.
+type LayerResourceIDRow struct {
+ LayerResourceID int32 `json:"layer_resource_id"`
+ LayerResourceKind string `json:"layer_resource_kind"` // DRBD | STORAGE | LUKS | ...
+ LayerResourceParentID *int32 `json:"layer_resource_parent_id,omitempty"`
+ LayerResourceSuffix string `json:"layer_resource_suffix,omitempty"`
+ LayerResourceSuspended bool `json:"layer_resource_suspended,omitempty"`
+ NodeName string `json:"node_name"`
+ ResourceName string `json:"resource_name"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+}
+
+// LayerDrbdResourceDefinitionRow is one row of
+// LAYER_DRBD_RESOURCE_DEFINITIONS — RD-scoped DRBD config.
+type LayerDrbdResourceDefinitionRow struct {
+ ResourceName string `json:"resource_name"`
+ ResourceNameSuffix string `json:"resource_name_suffix,omitempty"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ PeerSlots int32 `json:"peer_slots"`
+ AlStripes int64 `json:"al_stripes"`
+ AlStripeSize int64 `json:"al_stripe_size"`
+ TransportType string `json:"transport_type,omitempty"`
+ Secret string `json:"secret,omitempty"`
+ TCPPort *int32 `json:"tcp_port,omitempty"`
+}
+
+// LayerDrbdResourceRow is one row of LAYER_DRBD_RESOURCES — per-replica
+// DRBD identity (node_id).
+type LayerDrbdResourceRow struct {
+ LayerResourceID int32 `json:"layer_resource_id"`
+ NodeID int32 `json:"node_id"`
+ PeerSlots int32 `json:"peer_slots"`
+ AlStripes int64 `json:"al_stripes"`
+ AlStripeSize int64 `json:"al_stripe_size"`
+ Flags int64 `json:"flags"`
+}
+
+// LayerDrbdVolumeDefinitionRow is one row of
+// LAYER_DRBD_VOLUME_DEFINITIONS — the per-volume DRBD minor.
+type LayerDrbdVolumeDefinitionRow struct {
+ ResourceName string `json:"resource_name"`
+ ResourceNameSuffix string `json:"resource_name_suffix,omitempty"`
+ SnapshotName string `json:"snapshot_name,omitempty"`
+ VlmNr int32 `json:"vlm_nr"`
+ VlmMinorNr *int32 `json:"vlm_minor_nr,omitempty"`
+}
+
+// LayerDrbdVolumeRow is one row of LAYER_DRBD_VOLUMES.
+type LayerDrbdVolumeRow struct {
+ LayerResourceID int32 `json:"layer_resource_id"`
+ VlmNr int32 `json:"vlm_nr"`
+}
+
+// LayerStorageVolumeRow is one row of LAYER_STORAGE_VOLUMES — which
+// storage pool backs each volume of each layer instance.
+type LayerStorageVolumeRow struct {
+ LayerResourceID int32 `json:"layer_resource_id"`
+ VlmNr int32 `json:"vlm_nr"`
+ NodeName string `json:"node_name"`
+ ProviderKind string `json:"provider_kind"`
+ StorPoolName string `json:"stor_pool_name"`
+}
+
+// LayerLuksVolumeRow is one row of LAYER_LUKS_VOLUMES — the per-volume
+// LUKS passphrase encrypted with the LINSTOR master key.
+type LayerLuksVolumeRow struct {
+ LayerResourceID int32 `json:"layer_resource_id"`
+ VlmNr int32 `json:"vlm_nr"`
+ EncryptedPassword string `json:"encrypted_password"`
+}
+
+// LinstorRemoteRow is one row of the LINSTOR_REMOTES table (linstor↔
+// linstor backup-shipping targets). Not migratable to blockstor; the
+// converter reports any operator-created entry.
+type LinstorRemoteRow struct {
+ Name string `json:"name"`
+ DspName string `json:"dsp_name"`
+ URL string `json:"url"`
+ ClusterID string `json:"cluster_id,omitempty"`
+ Flags int64 `json:"flags"`
+ UUID string `json:"uuid"`
+}
+
+// Dump is the loaded LINSTOR database.
+type Dump struct {
+ Nodes []NodeRow
+ NodeNetInterfaces []NodeNetInterfaceRow
+ NodeStorPools []NodeStorPoolRow
+ StorPoolDefinitions []StorPoolDefinitionRow
+ ResourceGroups []ResourceGroupRow
+ VolumeGroups []VolumeGroupRow
+ ResourceDefinitions []ResourceDefinitionRow
+ VolumeDefinitions []VolumeDefinitionRow
+ Resources []ResourceRow
+ Volumes []VolumeRow
+ PropsContainers []PropsContainerRow
+ LayerResourceIDs []LayerResourceIDRow
+ LayerDrbdResourceDefinitions []LayerDrbdResourceDefinitionRow
+ LayerDrbdResources []LayerDrbdResourceRow
+ LayerDrbdVolumeDefinitions []LayerDrbdVolumeDefinitionRow
+ LayerDrbdVolumes []LayerDrbdVolumeRow
+ LayerStorageVolumes []LayerStorageVolumeRow
+ LayerLuksVolumes []LayerLuksVolumeRow
+ LinstorRemotes []LinstorRemoteRow
+}
diff --git a/pkg/linstormigrate/ports.go b/pkg/linstormigrate/ports.go
new file mode 100644
index 00000000..2bb11425
--- /dev/null
+++ b/pkg/linstormigrate/ports.go
@@ -0,0 +1,71 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// ErrBadPortLine is returned when a DRBD port-map line is malformed.
+var ErrBadPortLine = errors.New("malformed DRBD port line")
+
+// maxTCPPort is the inclusive upper bound of a valid TCP port.
+const maxTCPPort = 65535
+
+// ParseDRBDPorts parses a live DRBD port map: one ` `
+// per line (whitespace-separated), blank lines and `#` comments
+// ignored. Keys are lowercased so lookups are case-insensitive against
+// LINSTOR's uppercase resource names. Capture it from the running
+// cluster before cutover so adoption preserves each mesh's endpoint —
+// e.g. per satellite pod:
+//
+// 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
+//
+// (union across nodes; every replica of an RD shares one port).
+func ParseDRBDPorts(content string) (map[string]int32, error) {
+ ports := map[string]int32{}
+
+ for i, raw := range strings.Split(content, "\n") {
+ text := strings.TrimSpace(raw)
+ if text == "" || strings.HasPrefix(text, "#") {
+ continue
+ }
+
+ fields := strings.Fields(text)
+ if len(fields) != 2 {
+ return nil, fmt.Errorf("%w at line %d: want ' ', got %q", ErrBadPortLine, i+1, text)
+ }
+
+ port, err := strconv.ParseInt(fields[1], 10, 32)
+ if err != nil || port <= 0 || port > maxTCPPort {
+ return nil, fmt.Errorf("%w at line %d: invalid port %q", ErrBadPortLine, i+1, fields[1])
+ }
+
+ ports[strings.ToLower(fields[0])] = int32(port)
+ }
+
+ return ports, nil
+}
diff --git a/pkg/linstormigrate/props.go b/pkg/linstormigrate/props.go
new file mode 100644
index 00000000..13d77d81
--- /dev/null
+++ b/pkg/linstormigrate/props.go
@@ -0,0 +1,145 @@
+// 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.
+*/
+
+package linstormigrate
+
+import (
+ "maps"
+ "strings"
+)
+
+// PropsIndex groups the flat PROPS_CONTAINERS rows by owner object.
+// LINSTOR keys each property row by an instance path; the shapes are:
+//
+// /CTRL controller
+// /NODES/ node
+// /STOR_POOLS// storage pool
+// /RSC_GRPS/ resource group
+// /RSC_DFNS/ resource definition
+// /VLM_DFNS// volume definition
+// /RSCS// resource (replica)
+// /VLMS/// volume (replica volume)
+// /SNAP_DFNS// snapshot definition
+// /SNAP_DFNS_RSC_DFN// RD props frozen into the snapshot
+// /SNAP_VLM_DFNS_VLM_DFN/// VD props frozen into the snapshot
+// /SNAPS/// per-node snapshot
+// /SNAPS_RSC/// resource props frozen into the snapshot
+// /SNAP_VLMS_VLM//// volume props frozen into the snapshot
+//
+// All identifiers in the paths are the UPPERCASE key names
+// (resource_name, not resource_dsp_name).
+type PropsIndex struct {
+ // Controller is the /CTRL property bag.
+ Controller map[string]string
+
+ byInstance map[string]map[string]string
+}
+
+// NewPropsIndex builds the lookup from the raw table rows.
+func NewPropsIndex(rows []PropsContainerRow) *PropsIndex {
+ idx := &PropsIndex{
+ Controller: map[string]string{},
+ byInstance: map[string]map[string]string{},
+ }
+
+ for _, row := range rows {
+ if row.PropsInstance == "/CTRL" {
+ idx.Controller[row.PropKey] = row.PropValue
+
+ continue
+ }
+
+ bag := idx.byInstance[row.PropsInstance]
+ if bag == nil {
+ bag = map[string]string{}
+ idx.byInstance[row.PropsInstance] = bag
+ }
+
+ bag[row.PropKey] = row.PropValue
+ }
+
+ return idx
+}
+
+// Node returns the /NODES/ props.
+func (idx *PropsIndex) Node(node string) map[string]string {
+ return idx.get("NODES", node)
+}
+
+// StorPool returns the /STOR_POOLS// props.
+func (idx *PropsIndex) StorPool(node, pool string) map[string]string {
+ return idx.get("STOR_POOLS", node, pool)
+}
+
+// ResourceGroup returns the /RSC_GRPS/ props.
+func (idx *PropsIndex) ResourceGroup(rg string) map[string]string {
+ return idx.get("RSC_GRPS", rg)
+}
+
+// ResourceDefinition returns the /RSC_DFNS/ props.
+func (idx *PropsIndex) ResourceDefinition(rd string) map[string]string {
+ return idx.get("RSC_DFNS", rd)
+}
+
+// VolumeDefinition returns the /VLM_DFNS// props.
+func (idx *PropsIndex) VolumeDefinition(rd, vlmNr string) map[string]string {
+ return idx.get("VLM_DFNS", rd, vlmNr)
+}
+
+// Resource returns the /RSCS// props.
+func (idx *PropsIndex) Resource(node, rd string) map[string]string {
+ return idx.get("RSCS", node, rd)
+}
+
+// Volume returns the /VLMS/// props.
+func (idx *PropsIndex) Volume(node, rd, vlmNr string) map[string]string {
+ return idx.get("VLMS", node, rd, vlmNr)
+}
+
+// SnapshotDefinition returns the /SNAP_DFNS// props.
+func (idx *PropsIndex) SnapshotDefinition(rd, snap string) map[string]string {
+ return idx.get("SNAP_DFNS", rd, snap)
+}
+
+// SnapshotRDFrozen returns the /SNAP_DFNS_RSC_DFN// props —
+// the parent RD's property bag frozen at snapshot time.
+func (idx *PropsIndex) SnapshotRDFrozen(rd, snap string) map[string]string {
+ return idx.get("SNAP_DFNS_RSC_DFN", rd, snap)
+}
+
+// SnapshotVDFrozen returns the
+// /SNAP_VLM_DFNS_VLM_DFN/// props — the parent VD's
+// property bag frozen at snapshot time.
+func (idx *PropsIndex) SnapshotVDFrozen(rd, snap, vlmNr string) map[string]string {
+ return idx.get("SNAP_VLM_DFNS_VLM_DFN", rd, snap, vlmNr)
+}
+
+// get returns a copy of the property bag at the given instance path
+// (nil when the object has no props). Copies so converters can mutate
+// (pop consumed keys) without corrupting the index.
+func (idx *PropsIndex) get(segments ...string) map[string]string {
+ bag := idx.byInstance["/"+strings.Join(segments, "/")]
+ if len(bag) == 0 {
+ return nil
+ }
+
+ out := make(map[string]string, len(bag))
+ maps.Copy(out, bag)
+
+ return out
+}
diff --git a/pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..bea1b503
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json
@@ -0,0 +1,6 @@
+{"items":[
+{"spec":{"al_stripe_size":32,"al_stripes":1,"peer_slots":7,"resource_name":"PVC-VOL1","resource_name_suffix":"","secret":"fixture-secret-vol1","snapshot_name":"","tcp_port":7001,"transport_type":"IP"}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"peer_slots":7,"resource_name":"PVC-VOL3","resource_name_suffix":"","secret":"fixture-secret-vol3","snapshot_name":"","transport_type":"IP"}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"peer_slots":7,"resource_name":"PVC-VOL5","resource_name_suffix":"","secret":"fixture-secret-vol5","snapshot_name":"","transport_type":"IP"}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"peer_slots":7,"resource_name":"PVC-VOL1","resource_name_suffix":"","snapshot_name":"SNAP-GOOD","transport_type":"IP"}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..82f02d97
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json
@@ -0,0 +1,9 @@
+{"items":[
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":1,"node_id":0,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":3,"node_id":1,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":5,"node_id":2,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":8,"node_id":0,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":11,"node_id":1,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":14,"node_id":0,"peer_slots":7}},
+{"spec":{"al_stripe_size":32,"al_stripes":1,"flags":0,"layer_resource_id":16,"node_id":1,"peer_slots":7}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..d791bd13
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json
@@ -0,0 +1,48 @@
+{
+"items": [
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"resource_name_suffix": "",
+"snapshot_name": "",
+"vlm_minor_nr": 1001,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL3",
+"resource_name_suffix": "",
+"snapshot_name": "",
+"vlm_minor_nr": 1003,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL5",
+"resource_name_suffix": "",
+"snapshot_name": "",
+"vlm_minor_nr": 1005,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"resource_name_suffix": "",
+"snapshot_name": "SNAP-GOOD",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"resource_name_suffix": "",
+"snapshot_name": "",
+"vlm_minor_nr": 1042,
+"vlm_nr": 1
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/layerluksvolumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerluksvolumes.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..5394b37f
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerluksvolumes.internal.linstor.linbit.com.json
@@ -0,0 +1,4 @@
+{"items":[
+{"spec":{"encrypted_password":"Zml4dHVyZS1lbmNyeXB0ZWQtcGFzcy1BQUFB","layer_resource_id":9,"vlm_nr":0}},
+{"spec":{"encrypted_password":"Zml4dHVyZS1lbmNyeXB0ZWQtcGFzcy1CQkJC","layer_resource_id":12,"vlm_nr":0}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..87e82f58
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json
@@ -0,0 +1,244 @@
+{
+"items": [
+{
+"spec": {
+"layer_resource_id": 1,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 2,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 1,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 3,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 4,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 3,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 5,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-C",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 6,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 5,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-C",
+"resource_name": "PVC-VOL1",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 7,
+"layer_resource_kind": "STORAGE",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL2",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 8,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 9,
+"layer_resource_kind": "LUKS",
+"layer_resource_parent_id": 8,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 10,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 9,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 11,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 12,
+"layer_resource_kind": "LUKS",
+"layer_resource_parent_id": 11,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 13,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 12,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL3",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 14,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL5",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 15,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 14,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL5",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 16,
+"layer_resource_kind": "DRBD",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL5",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 17,
+"layer_resource_kind": "STORAGE",
+"layer_resource_parent_id": 16,
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL5",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 18,
+"layer_resource_kind": "STORAGE",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD"
+}
+},
+{
+"spec": {
+"layer_resource_id": 19,
+"layer_resource_kind": "STORAGE",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD"
+}
+},
+{
+"spec": {
+"layer_resource_id": 30,
+"layer_resource_kind": "STORAGE",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL6",
+"snapshot_name": ""
+}
+},
+{
+"spec": {
+"layer_resource_id": 31,
+"layer_resource_kind": "STORAGE",
+"layer_resource_suffix": "",
+"layer_resource_suspended": false,
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL7",
+"snapshot_name": ""
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..1832b6ea
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json
@@ -0,0 +1,121 @@
+{
+"items": [
+{
+"spec": {
+"layer_resource_id": 2,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 4,
+"node_name": "NODE-B",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 7,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 10,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 13,
+"node_name": "NODE-B",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 15,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 17,
+"node_name": "NODE-B",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 18,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 2,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 1
+}
+},
+{
+"spec": {
+"layer_resource_id": 4,
+"node_name": "NODE-B",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 1
+}
+},
+{
+"spec": {
+"layer_resource_id": 30,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 31,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "DATA",
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"layer_resource_id": 31,
+"node_name": "NODE-A",
+"provider_kind": "ZFS",
+"stor_pool_name": "FAST",
+"vlm_nr": 1
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/linstorremotes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/linstorremotes.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..78e26bf6
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/linstorremotes.internal.linstor.linbit.com.json
@@ -0,0 +1,4 @@
+{"items":[
+{"spec":{"cluster_id":"00000000-1111-2222-3333-444444444444","dsp_name":"local-remote-generated-by-linstor","flags":0,"name":"LOCAL-REMOTE-GENERATED-BY-LINSTOR","url":"http://localhost:3370","uuid":"a0000000-0000-0000-0000-000000000001"}},
+{"spec":{"cluster_id":"","dsp_name":"offsite-dr","flags":0,"name":"OFFSITE-DR","url":"linstor://dr.example.test","uuid":"a0000000-0000-0000-0000-000000000002"}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/nodenetinterfaces.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/nodenetinterfaces.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..de40c7e0
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/nodenetinterfaces.internal.linstor.linbit.com.json
@@ -0,0 +1,5 @@
+{"items":[
+{"spec":{"inet_address":"10.10.0.1","node_name":"NODE-A","node_net_dsp_name":"default-ipv4","node_net_name":"DEFAULT-IPV4","stlt_conn_encr_type":"SSL","stlt_conn_port":3367,"uuid":"10000000-0000-0000-0000-00000000000a"}},
+{"spec":{"inet_address":"10.10.0.2","node_name":"NODE-B","node_net_dsp_name":"default-ipv4","node_net_name":"DEFAULT-IPV4","stlt_conn_encr_type":"SSL","stlt_conn_port":3367,"uuid":"10000000-0000-0000-0000-00000000000b"}},
+{"spec":{"inet_address":"10.10.0.3","node_name":"NODE-C","node_net_dsp_name":"default-ipv4","node_net_name":"DEFAULT-IPV4","stlt_conn_encr_type":"SSL","stlt_conn_port":3367,"uuid":"10000000-0000-0000-0000-00000000000c"}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/nodes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/nodes.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..0b557ea6
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/nodes.internal.linstor.linbit.com.json
@@ -0,0 +1,5 @@
+{"items":[
+{"spec":{"node_dsp_name":"node-a","node_flags":0,"node_name":"NODE-A","node_type":2,"uuid":"00000000-0000-0000-0000-00000000000a"}},
+{"spec":{"node_dsp_name":"node-b","node_flags":0,"node_name":"NODE-B","node_type":2,"uuid":"00000000-0000-0000-0000-00000000000b"}},
+{"spec":{"node_dsp_name":"node-c","node_flags":0,"node_name":"NODE-C","node_type":2,"uuid":"00000000-0000-0000-0000-00000000000c"}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..fb0da486
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json
@@ -0,0 +1,48 @@
+{
+"items": [
+{
+"spec": {
+"driver_name": "ZFS",
+"external_locking": false,
+"free_space_mgr_dsp_name": "node-a;data",
+"free_space_mgr_name": "NODE-A;DATA",
+"node_name": "NODE-A",
+"pool_name": "DATA",
+"uuid": "30000000-0000-0000-0000-00000000000a"
+}
+},
+{
+"spec": {
+"driver_name": "ZFS",
+"external_locking": false,
+"free_space_mgr_dsp_name": "node-b;data",
+"free_space_mgr_name": "NODE-B;DATA",
+"node_name": "NODE-B",
+"pool_name": "DATA",
+"uuid": "30000000-0000-0000-0000-00000000000b"
+}
+},
+{
+"spec": {
+"driver_name": "DISKLESS",
+"external_locking": false,
+"free_space_mgr_dsp_name": "node-c;DfltDisklessStorPool",
+"free_space_mgr_name": "NODE-C;DFLTDISKLESSSTORPOOL",
+"node_name": "NODE-C",
+"pool_name": "DFLTDISKLESSSTORPOOL",
+"uuid": "30000000-0000-0000-0000-00000000000c"
+}
+},
+{
+"spec": {
+"driver_name": "ZFS",
+"external_locking": false,
+"free_space_mgr_dsp_name": "node-a;fast",
+"free_space_mgr_name": "NODE-A;FAST",
+"node_name": "NODE-A",
+"pool_name": "FAST",
+"uuid": "30000000-0000-0000-0000-00000000000f"
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..6c10a06d
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json
@@ -0,0 +1,193 @@
+{
+"items": [
+{
+"spec": {
+"prop_key": "Aux/topology/kubernetes.io/hostname",
+"prop_value": "node-a",
+"props_instance": "/NODES/NODE-A"
+}
+},
+{
+"spec": {
+"prop_key": "CurStltConnName",
+"prop_value": "default-ipv4",
+"props_instance": "/NODES/NODE-A"
+}
+},
+{
+"spec": {
+"prop_key": "StorDriver/StorPoolName",
+"prop_value": "data",
+"props_instance": "/STOR_POOLS/NODE-A/DATA"
+}
+},
+{
+"spec": {
+"prop_key": "StorDriver/internal/AllocationGranularity",
+"prop_value": "16",
+"props_instance": "/STOR_POOLS/NODE-A/DATA"
+}
+},
+{
+"spec": {
+"prop_key": "StorDriver/StorPoolName",
+"prop_value": "data",
+"props_instance": "/STOR_POOLS/NODE-B/DATA"
+}
+},
+{
+"spec": {
+"prop_key": "StorDriver/internal/AllocationGranularity",
+"prop_value": "16",
+"props_instance": "/STOR_POOLS/NODE-B/DATA"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/Net/protocol",
+"prop_value": "A",
+"props_instance": "/RSC_GRPS/SC-REPLICATED"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/Resource/quorum",
+"prop_value": "majority",
+"props_instance": "/RSC_GRPS/SC-REPLICATED"
+}
+},
+{
+"spec": {
+"prop_key": "Aux/csi.storage.k8s.io/pvc/name",
+"prop_value": "app-data",
+"props_instance": "/RSC_DFNS/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "Aux/csi.storage.k8s.io/pvc/namespace",
+"prop_value": "tenant-fixture",
+"props_instance": "/RSC_DFNS/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/Resource/quorum",
+"prop_value": "majority",
+"props_instance": "/RSC_DFNS/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/auto-verify-alg",
+"prop_value": "crct10dif",
+"props_instance": "/RSC_DFNS/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "FileSystem/Type",
+"prop_value": "ext4",
+"props_instance": "/RSC_DFNS/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/Net/allow-two-primaries",
+"prop_value": "yes",
+"props_instance": "/RSC_DFNS/PVC-VOL5"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdCurrentGi",
+"prop_value": "F1E2D3C4B5A69788",
+"props_instance": "/VLM_DFNS/PVC-VOL1/0"
+}
+},
+{
+"spec": {
+"prop_key": "StorPoolName",
+"prop_value": "data",
+"props_instance": "/RSCS/NODE-A/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "StorPoolName",
+"prop_value": "data",
+"props_instance": "/RSCS/NODE-B/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "StorPoolName",
+"prop_value": "DfltDisklessStorPool",
+"props_instance": "/RSCS/NODE-C/PVC-VOL1"
+}
+},
+{
+"spec": {
+"prop_key": "StorPoolName",
+"prop_value": "data",
+"props_instance": "/VLMS/NODE-A/PVC-VOL1/0"
+}
+},
+{
+"spec": {
+"prop_key": "SequenceNumber",
+"prop_value": "1",
+"props_instance": "/SNAP_DFNS/PVC-VOL1/SNAP-GOOD"
+}
+},
+{
+"spec": {
+"prop_key": "Aux/csi.storage.k8s.io/pvc/name",
+"prop_value": "app-data",
+"props_instance": "/SNAP_DFNS_RSC_DFN/PVC-VOL1/SNAP-GOOD"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/auto-add-quorum-tiebreaker",
+"prop_value": "True",
+"props_instance": "/CTRL"
+}
+},
+{
+"spec": {
+"prop_key": "DrbdOptions/auto-verify-algo-allowed-list",
+"prop_value": "crct10dif;crc32c;sha256",
+"props_instance": "/CTRL"
+}
+},
+{
+"spec": {
+"prop_key": "NetCom/SslConnector/Port",
+"prop_value": "3377",
+"props_instance": "/CTRL"
+}
+},
+{
+"spec": {
+"prop_key": "Cluster/LocalID",
+"prop_value": "00000000-1111-2222-3333-444444444444",
+"props_instance": "/CTRL"
+}
+},
+{
+"spec": {
+"prop_key": "encryptedMasterKey",
+"prop_value": "fixture-master-crypto-material",
+"props_instance": "/CTRL"
+}
+},
+{
+"spec": {
+"prop_key": "StorDriver/StorPoolName",
+"prop_value": "fast",
+"props_instance": "/STOR_POOLS/NODE-A/FAST"
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..700ea9ff
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json
@@ -0,0 +1,114 @@
+{
+"items": [
+{
+"spec": {
+"layer_stack": "[\"DRBD\",\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol1",
+"resource_flags": 0,
+"resource_group_name": "SC-REPLICATED",
+"resource_name": "PVC-VOL1",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000001"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol2",
+"resource_flags": 0,
+"resource_group_name": "DFLTRSCGRP",
+"resource_name": "PVC-VOL2",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000002"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"DRBD\",\"LUKS\",\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol3",
+"resource_flags": 0,
+"resource_group_name": "SC-ENCRYPTED",
+"resource_name": "PVC-VOL3",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000003"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"DRBD\",\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol4",
+"resource_flags": 2,
+"resource_group_name": "SC-REPLICATED",
+"resource_name": "PVC-VOL4",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000004"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"DRBD\",\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol5",
+"resource_flags": 0,
+"resource_group_name": "SC-REPLICATED",
+"resource_name": "PVC-VOL5",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000005"
+}
+},
+{
+"spec": {
+"layer_stack": "[]",
+"parent_uuid": "60000000-0000-0000-0000-000000000001",
+"resource_dsp_name": "pvc-vol1",
+"resource_flags": 1,
+"resource_group_name": "SC-REPLICATED",
+"resource_name": "PVC-VOL1",
+"snapshot_dsp_name": "snap-good",
+"snapshot_name": "SNAP-GOOD",
+"uuid": "60000000-0000-0000-0000-000000000006"
+}
+},
+{
+"spec": {
+"layer_stack": "[]",
+"parent_uuid": "60000000-0000-0000-0000-000000000001",
+"resource_dsp_name": "pvc-vol1",
+"resource_flags": 2,
+"resource_group_name": "SC-REPLICATED",
+"resource_name": "PVC-VOL1",
+"snapshot_dsp_name": "snap-bad",
+"snapshot_name": "SNAP-BAD",
+"uuid": "60000000-0000-0000-0000-000000000007"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol6",
+"resource_flags": 0,
+"resource_group_name": "SC-GHOST",
+"resource_name": "PVC-VOL6",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-000000000009"
+}
+},
+{
+"spec": {
+"layer_stack": "[\"STORAGE\"]",
+"resource_dsp_name": "pvc-vol7",
+"resource_flags": 0,
+"resource_group_name": "DFLTRSCGRP",
+"resource_name": "PVC-VOL7",
+"snapshot_dsp_name": "",
+"snapshot_name": "",
+"uuid": "60000000-0000-0000-0000-00000000000a"
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..3bb36378
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json
@@ -0,0 +1,5 @@
+{"items":[
+{"spec":{"allowed_provider_list":"[]","description":"","do_not_place_with_rsc_list":"[]","layer_stack":"[\"DRBD\",\"STORAGE\"]","node_name_list":"[]","pool_name":"[\"data\"]","pool_name_diskless":"[]","replica_count":2,"replicas_on_different":"[]","replicas_on_same":"[]","resource_group_dsp_name":"sc-replicated","resource_group_name":"SC-REPLICATED","uuid":"40000000-0000-0000-0000-000000000001"}},
+{"spec":{"allowed_provider_list":"[]","description":"encrypted volumes","do_not_place_with_rsc_list":"[]","layer_stack":"[\"DRBD\",\"LUKS\",\"STORAGE\"]","node_name_list":"[]","pool_name":"[\"data\"]","pool_name_diskless":"[]","replica_count":2,"replicas_on_different":"[]","replicas_on_same":"[]","resource_group_dsp_name":"sc-encrypted","resource_group_name":"SC-ENCRYPTED","uuid":"40000000-0000-0000-0000-000000000002"}},
+{"spec":{"allowed_provider_list":"[]","description":"","do_not_place_with_rsc_list":"[]","layer_stack":null,"node_name_list":"[]","pool_name":"[]","pool_name_diskless":"[]","replica_count":2,"replicas_on_different":"[]","replicas_on_same":"[]","resource_group_dsp_name":"DfltRscGrp","resource_group_name":"DFLTRSCGRP","uuid":"40000000-0000-0000-0000-000000000003"}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..91a0ffc6
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json
@@ -0,0 +1,144 @@
+{
+"items": [
+{
+"spec": {
+"create_timestamp": 1770000000001,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000001"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000002,
+"node_name": "NODE-B",
+"resource_flags": 128,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000002"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000003,
+"node_name": "NODE-C",
+"resource_flags": 388,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000003"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000004,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL2",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000004"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000005,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL3",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000005"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000006,
+"node_name": "NODE-B",
+"resource_flags": 0,
+"resource_name": "PVC-VOL3",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000006"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000007,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL4",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000007"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000008,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL5",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000008"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000009,
+"node_name": "NODE-B",
+"resource_flags": 2048,
+"resource_name": "PVC-VOL5",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000009"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000010,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD",
+"uuid": "80000000-0000-0000-0000-00000000000a"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000011,
+"node_name": "NODE-B",
+"resource_flags": 0,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD",
+"uuid": "80000000-0000-0000-0000-00000000000b"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000012,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-BAD",
+"uuid": "80000000-0000-0000-0000-00000000000c"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000020,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL6",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000020"
+}
+},
+{
+"spec": {
+"create_timestamp": 1770000000030,
+"node_name": "NODE-A",
+"resource_flags": 0,
+"resource_name": "PVC-VOL7",
+"snapshot_name": "",
+"uuid": "80000000-0000-0000-0000-000000000030"
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..8253f9ca
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json
@@ -0,0 +1,25 @@
+{
+"items": [
+{
+"spec": {
+"pool_dsp_name": "DfltDisklessStorPool",
+"pool_name": "DFLTDISKLESSSTORPOOL",
+"uuid": "20000000-0000-0000-0000-000000000001"
+}
+},
+{
+"spec": {
+"pool_dsp_name": "data",
+"pool_name": "DATA",
+"uuid": "20000000-0000-0000-0000-000000000002"
+}
+},
+{
+"spec": {
+"pool_dsp_name": "fast",
+"pool_name": "FAST",
+"uuid": "20000000-0000-0000-0000-000000000003"
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..7ab61bd0
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json
@@ -0,0 +1,124 @@
+{
+"items": [
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000001",
+"vlm_flags": 32,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL2",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000002",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 524288
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL3",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000003",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 2097152
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL4",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000004",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL5",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000005",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD",
+"uuid": "70000000-0000-0000-0000-000000000006",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-BAD",
+"uuid": "70000000-0000-0000-0000-000000000007",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000008",
+"vlm_flags": 0,
+"vlm_nr": 1,
+"vlm_size": 307200
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL6",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-000000000009",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL7",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-00000000000a",
+"vlm_flags": 0,
+"vlm_nr": 0,
+"vlm_size": 1048576
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL7",
+"snapshot_name": "",
+"uuid": "70000000-0000-0000-0000-00000000000b",
+"vlm_flags": 0,
+"vlm_nr": 1,
+"vlm_size": 524288
+}
+},
+{
+"spec": {
+"resource_name": "PVC-VOL1",
+"snapshot_name": "SNAP-GOOD",
+"uuid": "70000000-0000-0000-0000-00000000000c",
+"vlm_flags": 0,
+"vlm_nr": 1,
+"vlm_size": 307200
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/dump/volumegroups.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumegroups.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..fffe50d5
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/volumegroups.internal.linstor.linbit.com.json
@@ -0,0 +1,4 @@
+{"items":[
+{"spec":{"flags":0,"resource_group_name":"SC-REPLICATED","uuid":"50000000-0000-0000-0000-000000000001","vlm_nr":0}},
+{"spec":{"flags":0,"resource_group_name":"SC-ENCRYPTED","uuid":"50000000-0000-0000-0000-000000000002","vlm_nr":0}}
+]}
diff --git a/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json
new file mode 100644
index 00000000..32a27d2b
--- /dev/null
+++ b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json
@@ -0,0 +1,114 @@
+{
+"items": [
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000001",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000002",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-C",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000003",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL2",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000004",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL3",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000005",
+"vlm_flags": 64,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL3",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000006",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL5",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000007",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL5",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000008",
+"vlm_flags": 0,
+"vlm_nr": 0
+}
+},
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-000000000009",
+"vlm_flags": 0,
+"vlm_nr": 1
+}
+},
+{
+"spec": {
+"node_name": "NODE-B",
+"resource_name": "PVC-VOL1",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-00000000000a",
+"vlm_flags": 0,
+"vlm_nr": 1
+}
+},
+{
+"spec": {
+"node_name": "NODE-A",
+"resource_name": "PVC-VOL2",
+"snapshot_name": "",
+"uuid": "90000000-0000-0000-0000-0000000000ff",
+"vlm_flags": 64,
+"vlm_nr": 0
+}
+}
+]
+}
diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml
new file mode 100644
index 00000000..e3601235
--- /dev/null
+++ b/pkg/linstormigrate/testdata/golden/manifests.yaml
@@ -0,0 +1,388 @@
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ControllerConfig
+metadata:
+ name: default
+spec:
+ extraProps:
+ DrbdOptions/auto-add-quorum-tiebreaker: "True"
+ DrbdOptions/auto-verify-algo-allowed-list: crct10dif;crc32c;sha256
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Node
+metadata:
+ name: node-a
+spec:
+ netInterfaces:
+ - address: 10.10.0.1
+ name: default-ipv4
+ satelliteEncryptionType: SSL
+ satellitePort: 3367
+ props:
+ Aux/topology/kubernetes.io/hostname: node-a
+ CurStltConnName: default-ipv4
+ type: SATELLITE
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Node
+metadata:
+ name: node-b
+spec:
+ netInterfaces:
+ - address: 10.10.0.2
+ name: default-ipv4
+ satelliteEncryptionType: SSL
+ satellitePort: 3367
+ type: SATELLITE
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Node
+metadata:
+ name: node-c
+spec:
+ netInterfaces:
+ - address: 10.10.0.3
+ name: default-ipv4
+ satelliteEncryptionType: SSL
+ satellitePort: 3367
+ type: SATELLITE
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: StoragePool
+metadata:
+ name: data.node-a
+spec:
+ nodeName: node-a
+ poolName: data
+ props:
+ StorDriver/StorPoolName: data
+ StorDriver/internal/AllocationGranularity: "16"
+ providerKind: ZFS
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: StoragePool
+metadata:
+ name: data.node-b
+spec:
+ nodeName: node-b
+ poolName: data
+ props:
+ StorDriver/StorPoolName: data
+ StorDriver/internal/AllocationGranularity: "16"
+ providerKind: ZFS
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: StoragePool
+metadata:
+ name: dfltdisklessstorpool.node-c
+spec:
+ nodeName: node-c
+ poolName: DfltDisklessStorPool
+ providerKind: DISKLESS
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: StoragePool
+metadata:
+ name: fast.node-a
+spec:
+ nodeName: node-a
+ poolName: fast
+ props:
+ StorDriver/StorPoolName: fast
+ providerKind: ZFS
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceGroup
+metadata:
+ annotations:
+ blockstor.io/linstor-name: DfltRscGrp
+ name: dfltrscgrp
+spec:
+ selectFilter:
+ placeCount: 2
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceGroup
+metadata:
+ name: sc-encrypted
+spec:
+ description: encrypted volumes
+ selectFilter:
+ layerStack:
+ - DRBD
+ - LUKS
+ - STORAGE
+ placeCount: 2
+ storagePoolList:
+ - data
+ volumeGroups:
+ - volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceGroup
+metadata:
+ name: sc-replicated
+spec:
+ drbdOptions:
+ net:
+ protocol: A
+ resource:
+ quorum: majority
+ selectFilter:
+ layerStack:
+ - DRBD
+ - STORAGE
+ placeCount: 2
+ storagePoolList:
+ - data
+ volumeGroups:
+ - volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol1
+spec:
+ drbdOptions:
+ resource:
+ quorum: majority
+ drbdPort: 7001
+ extraProps:
+ Aux/csi.storage.k8s.io/pvc/name: app-data
+ Aux/csi.storage.k8s.io/pvc/namespace: tenant-fixture
+ DrbdOptions/Net/shared-secret: fixture-secret-vol1
+ DrbdOptions/auto-verify-alg: crct10dif
+ FileSystem/Type: ext4
+ initialized: true
+ layerStack:
+ - DRBD
+ - STORAGE
+ props:
+ Aux/csi.storage.k8s.io/pvc/name: app-data
+ Aux/csi.storage.k8s.io/pvc/namespace: tenant-fixture
+ FileSystem/Type: ext4
+ resourceGroupName: sc-replicated
+ volumeDefinitions:
+ - drbdMinor: 1001
+ props:
+ DrbdCurrentGi: F1E2D3C4B5A69788
+ sizeKib: 1048576
+ volumeNumber: 0
+ - drbdMinor: 1042
+ sizeKib: 307200
+ volumeNumber: 1
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol2
+spec:
+ initialized: true
+ layerStack:
+ - STORAGE
+ resourceGroupName: DfltRscGrp
+ volumeDefinitions:
+ - sizeKib: 524288
+ volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol3
+spec:
+ extraProps:
+ DrbdOptions/Net/shared-secret: fixture-secret-vol3
+ initialized: true
+ layerStack:
+ - DRBD
+ - LUKS
+ - STORAGE
+ resourceGroupName: sc-encrypted
+ volumeDefinitions:
+ - drbdMinor: 1003
+ sizeKib: 2097152
+ volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol5
+spec:
+ drbdOptions:
+ net:
+ allowTwoPrimaries: true
+ extraProps:
+ DrbdOptions/Net/shared-secret: fixture-secret-vol5
+ initialized: true
+ layerStack:
+ - DRBD
+ - STORAGE
+ resourceGroupName: sc-replicated
+ volumeDefinitions:
+ - drbdMinor: 1005
+ sizeKib: 1048576
+ volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol6
+spec:
+ initialized: true
+ layerStack:
+ - STORAGE
+ volumeDefinitions:
+ - sizeKib: 1048576
+ volumeNumber: 0
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: ResourceDefinition
+metadata:
+ name: pvc-vol7
+spec:
+ initialized: true
+ layerStack:
+ - STORAGE
+ resourceGroupName: DfltRscGrp
+ volumeDefinitions:
+ - sizeKib: 1048576
+ volumeNumber: 0
+ - sizeKib: 524288
+ volumeNumber: 1
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol1.node-a
+spec:
+ drbdNodeID: 0
+ drbdPort: 7001
+ extraProps:
+ StorPoolName: data
+ nodeName: node-a
+ props:
+ StorPoolName: data
+ resourceDefinitionName: pvc-vol1
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol1.node-b
+spec:
+ drbdNodeID: 1
+ drbdPort: 7001
+ extraProps:
+ StorPoolName: data
+ nodeName: node-b
+ props:
+ StorPoolName: data
+ resourceDefinitionName: pvc-vol1
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol1.node-c
+spec:
+ drbdNodeID: 2
+ drbdPort: 7001
+ extraProps:
+ StorPoolName: DfltDisklessStorPool
+ flags:
+ - DISKLESS
+ - DRBD_DISKLESS
+ - TIE_BREAKER
+ nodeName: node-c
+ props:
+ StorPoolName: DfltDisklessStorPool
+ resourceDefinitionName: pvc-vol1
+ skipInitialSync: false
+ storagePool: DfltDisklessStorPool
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol2.node-a
+spec:
+ nodeName: node-a
+ resourceDefinitionName: pvc-vol2
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol3.node-a
+spec:
+ drbdNodeID: 0
+ nodeName: node-a
+ resourceDefinitionName: pvc-vol3
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol3.node-b
+spec:
+ drbdNodeID: 1
+ nodeName: node-b
+ resourceDefinitionName: pvc-vol3
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol5.node-a
+spec:
+ drbdNodeID: 0
+ nodeName: node-a
+ resourceDefinitionName: pvc-vol5
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol5.node-b
+spec:
+ drbdNodeID: 1
+ nodeName: node-b
+ resourceDefinitionName: pvc-vol5
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Resource
+metadata:
+ name: pvc-vol6.node-a
+spec:
+ nodeName: node-a
+ resourceDefinitionName: pvc-vol6
+ skipInitialSync: false
+ storagePool: data
+---
+apiVersion: blockstor.cozystack.io/v1alpha1
+kind: Snapshot
+metadata:
+ annotations:
+ blockstor.io/adopted: "true"
+ blockstor.io/adopted-created-at: "1770000000011"
+ name: pvc-vol1.snap-good
+spec:
+ nodes:
+ - node-a
+ - node-b
+ props:
+ SequenceNumber: "1"
+ resourceDefinitionName: pvc-vol1
+ snapshotName: snap-good
+ volumeDefinitions:
+ - sizeKib: 1048576
+ volumeNumber: 0
+ - sizeKib: 307200
+ volumeNumber: 1
diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt
new file mode 100644
index 00000000..595a36bb
--- /dev/null
+++ b/pkg/linstormigrate/testdata/golden/report.txt
@@ -0,0 +1,16 @@
+converted: 3 nodes, 4 storage pools, 3 resource groups, 6 resource definitions, 9 resources, 1 snapshots
+warning: volume definition pvc-vol1/0: unhandled flags bitmask 32 dropped
+warning: resource definition pvc-vol3: no DRBD port in the dump or live map — blockstor will allocate a fresh port; the adopted mesh will reconnect on the new port. Capture live ports (see runbook) to avoid the blip.
+warning: resource definition pvc-vol3: LUKS passphrase NOT migrated (encrypted with the LINSTOR master key) — provision spec.encryption manually before adopting
+warning: resource definition pvc-vol4: marked DELETE in the source cluster — skipped
+warning: resource definition pvc-vol5: no DRBD port in the dump or live map — blockstor will allocate a fresh port; the adopted mesh will reconnect on the new port. Capture live ports (see runbook) to avoid the blip.
+warning: resource definition pvc-vol6: resource group "SC-GHOST" was not migrated — reference cleared
+warning: resource pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128)
+warning: resource pvc-vol2.node-a volume 0: non-zero vlm_flags 64 (not decoded; blockstor has no per-volume flag field) — volume adopted as-is
+warning: resource pvc-vol3.node-a volume 0: non-zero vlm_flags 64 (not decoded; blockstor has no per-volume flag field) — volume adopted as-is
+warning: resource pvc-vol4.node-a: parent resource definition was not migrated — replica skipped
+warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048)
+warning: resource pvc-vol7.node-a: volumes span multiple storage pools (data, fast) but blockstor holds one pool per replica — replica skipped
+warning: snapshot pvc-vol1.snap-good: captured 2 volumes but blockstor addresses only volume 0 (/_00000@) — restore/delete of the other volumes will not find a dataset
+warning: snapshot pvc-vol1.snap-bad: FAILED_DEPLOYMENT in the source cluster — skipped
+warning: linstor remote offsite-dr (linstor://dr.example.test): backup-shipping remotes have no blockstor equivalent — not migrated
diff --git a/pkg/linstormigrate/validate_envtest_test.go b/pkg/linstormigrate/validate_envtest_test.go
new file mode 100644
index 00000000..b7cebd6c
--- /dev/null
+++ b/pkg/linstormigrate/validate_envtest_test.go
@@ -0,0 +1,196 @@
+// 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.
+*/
+
+package linstormigrate_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/envtest"
+
+ crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
+ lm "github.com/cozystack/blockstor/pkg/linstormigrate"
+)
+
+// TestConvertedManifestsPassCRDValidation boots a real kube-apiserver
+// (envtest), installs blockstor's actual CRDs, and server-side-applies
+// EVERY converted object. This proves the migrator's output satisfies
+// the real OpenAPI schema + every CEL XValidation rule (composite
+// . / . / . names, settable-once
+// drbdPort/drbdNodeID/initialized, enum constraints) — a plain Go
+// struct build cannot catch a CEL violation, only the apiserver can.
+//
+// The synthetic fixture (testdata/dump) always runs. The two
+// production dumps are validated too WHEN PRESENT at /tmp/infra and
+// /tmp/hidora (or $LINSTOR_DUMP_DIRS, colon-separated) — they carry
+// real secrets and never enter the repo, so their absence skips that
+// leg instead of failing.
+//
+// Requires envtest assets: run under `make test` or with
+// KUBEBUILDER_ASSETS set (the Makefile's setup-envtest target).
+func TestConvertedManifestsPassCRDValidation(t *testing.T) {
+ if os.Getenv("KUBEBUILDER_ASSETS") == "" {
+ t.Skip("KUBEBUILDER_ASSETS not set; run via `make test` (needs envtest apiserver)")
+ }
+
+ scheme := runtime.NewScheme()
+ if err := crdv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("add scheme: %v", err)
+ }
+
+ env := &envtest.Environment{
+ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
+ ErrorIfCRDPathMissing: true,
+ }
+
+ cfg, err := env.Start()
+ if err != nil {
+ t.Fatalf("start envtest: %v", err)
+ }
+
+ t.Cleanup(func() {
+ stopErr := env.Stop()
+ if stopErr != nil {
+ t.Logf("stop envtest: %v", stopErr)
+ }
+ })
+
+ k8s, err := client.New(cfg, client.Options{Scheme: scheme})
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ t.Run("synthetic-fixture", func(t *testing.T) {
+ res := loadAndConvert(t, filepath.Join("testdata", "dump"))
+ applyAll(t, k8s, res)
+ })
+
+ for _, dir := range dumpDirs() {
+ if _, statErr := os.Stat(dir); statErr != nil {
+ t.Logf("skipping production dump %s (absent)", dir)
+
+ continue
+ }
+
+ t.Run("production-dump:"+filepath.Base(dir), func(t *testing.T) {
+ res := loadAndConvert(t, dir)
+ applyAll(t, k8s, res)
+ })
+ }
+}
+
+func dumpDirs() []string {
+ if env := os.Getenv("LINSTOR_DUMP_DIRS"); env != "" {
+ return filepath.SplitList(env)
+ }
+
+ return []string{"/tmp/infra", "/tmp/hidora"}
+}
+
+func loadAndConvert(t *testing.T, dir string) *lm.Result {
+ t.Helper()
+
+ dump, err := lm.LoadDump(dir)
+ if err != nil {
+ t.Fatalf("LoadDump(%s): %v", dir, err)
+ }
+
+ res, err := lm.Convert(dump)
+ if err != nil {
+ t.Fatalf("Convert(%s): %v", dir, err)
+ }
+
+ return res
+}
+
+// applyAll server-side-applies every converted object into a fresh
+// apiserver and fails on the first rejection (schema or CEL). Uses a
+// unique field-manager so SSA owns the whole object.
+func applyAll(t *testing.T, k8s client.Client, res *lm.Result) {
+ t.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ objs := make([]client.Object, 0)
+
+ if res.ControllerConfig != nil {
+ objs = append(objs, res.ControllerConfig)
+ }
+
+ for i := range res.Nodes {
+ objs = append(objs, &res.Nodes[i])
+ }
+
+ for i := range res.StoragePools {
+ objs = append(objs, &res.StoragePools[i])
+ }
+
+ for i := range res.ResourceGroups {
+ objs = append(objs, &res.ResourceGroups[i])
+ }
+
+ for i := range res.ResourceDefinitions {
+ objs = append(objs, &res.ResourceDefinitions[i])
+ }
+
+ for i := range res.Resources {
+ objs = append(objs, &res.Resources[i])
+ }
+
+ for i := range res.Snapshots {
+ objs = append(objs, &res.Snapshots[i])
+ }
+
+ applied := 0
+
+ for _, obj := range objs {
+ // Deep-copy so the SSA patch doesn't mutate the shared object
+ // (envtest stamps managedFields / resourceVersion back onto it).
+ patch := obj.DeepCopyObject().(client.Object)
+
+ err := k8s.Patch(ctx, patch,
+ client.Apply, //nolint:staticcheck // SA1019: applyconfiguration-gen output not yet available for our CRDs
+ client.FieldOwner("linstor-migrate-validation"), client.ForceOwnership)
+ if err != nil {
+ t.Errorf("CRD validation rejected %T %q: %v",
+ obj, obj.GetName(), err)
+
+ continue
+ }
+
+ applied++
+ }
+
+ t.Logf("applied %d/%d objects with zero validation errors", applied, len(objs))
+
+ if applied != len(objs) {
+ t.Fatalf("%d object(s) failed CRD validation", len(objs)-applied)
+ }
+}
+
+// ensure metav1 import is used (SSA objects carry TypeMeta the
+// converter already sets; this keeps the import explicit for readers).
+var _ = metav1.TypeMeta{}
diff --git a/pkg/satellite/factory.go b/pkg/satellite/factory.go
index a5486e70..b713f7a8 100644
--- a/pkg/satellite/factory.go
+++ b/pkg/satellite/factory.go
@@ -19,6 +19,8 @@ limitations under the License.
package satellite
import (
+ "strings"
+
"github.com/cockroachdb/errors"
"github.com/cozystack/blockstor/pkg/storage"
@@ -44,11 +46,12 @@ const (
// kind. Mirrored verbatim so existing operators / piraeus-operator
// manifests round-trip.
const (
- propLvmVG = "StorDriver/LvmVg"
- propThinPool = "StorDriver/ThinPool"
- propZPool = "StorDriver/ZPool"
- propZPoolThin = "StorDriver/ZPoolThin"
- propFileDir = "StorDriver/FileDir"
+ propLvmVG = "StorDriver/LvmVg"
+ propThinPool = "StorDriver/ThinPool"
+ propZPool = "StorDriver/ZPool"
+ propZPoolThin = "StorDriver/ZPoolThin"
+ propFileDir = "StorDriver/FileDir"
+ propStorPoolName = "StorDriver/StorPoolName"
)
// NewProviderFromKind instantiates the matching `storage.Provider`
@@ -89,7 +92,16 @@ func NewProviderFromKind(kind string, props map[string]string, exec storage.Exec
func newLVMThick(props map[string]string, exec storage.Exec) (storage.Provider, error) {
vg := props[propLvmVG]
if vg == "" {
- return nil, errors.Errorf("LVM provider requires %q in props", propLvmVG)
+ // Same generic-key fallback as newZFS: a real LINSTOR database
+ // records the backing volume group under
+ // `StorDriver/StorPoolName` and leaves the kind-specific
+ // `StorDriver/LvmVg` blank, so an adopted LVM pool would
+ // otherwise never register a provider.
+ vg = props[propStorPoolName]
+ }
+
+ if vg == "" {
+ return nil, errors.Errorf("LVM provider requires %q or %q in props", propLvmVG, propStorPoolName)
}
return lvm.NewThick(lvm.ThickConfig{VolumeGroup: vg}, exec), nil
@@ -97,18 +109,55 @@ func newLVMThick(props map[string]string, exec storage.Exec) (storage.Provider,
func newLVMThin(props map[string]string, exec storage.Exec) (storage.Provider, error) {
vg := props[propLvmVG]
+ thinPool := props[propThinPool]
+
+ // Same generic-key fallback as newZFS / newLVMThick. A real LINSTOR
+ // database records an LVM_THIN pool as `/` in the
+ // single generic `StorDriver/StorPoolName` key and leaves both
+ // kind-specific keys blank; split it so an adopted thin pool
+ // registers instead of failing at cutover.
+ vg, thinPool = fillLVMThinFromGeneric(vg, thinPool, props[propStorPoolName])
+
if vg == "" {
- return nil, errors.Errorf("LVM_THIN provider requires %q in props", propLvmVG)
+ return nil, errors.Errorf("LVM_THIN provider requires %q or %q in props", propLvmVG, propStorPoolName)
}
- thinPool := props[propThinPool]
if thinPool == "" {
- return nil, errors.Errorf("LVM_THIN provider requires %q in props", propThinPool)
+ return nil, errors.Errorf("LVM_THIN provider requires %q, or %q as \"/\"", propThinPool, propStorPoolName)
}
return lvm.NewThin(lvm.ThinConfig{VolumeGroup: vg, ThinPool: thinPool}, exec), nil
}
+// fillLVMThinFromGeneric fills whichever of (vg, thinPool) is still
+// empty from LINSTOR's generic `StorDriver/StorPoolName` value, which
+// an LVM_THIN pool records as `/` (or just `` when
+// the thin pool lives under the kind-specific key).
+func fillLVMThinFromGeneric(vg, thinPool, generic string) (string, string) {
+ if generic == "" || (vg != "" && thinPool != "") {
+ return vg, thinPool
+ }
+
+ genericVG, genericThin, hasSeparator := strings.Cut(generic, "/")
+ if !hasSeparator {
+ if vg == "" {
+ vg = generic
+ }
+
+ return vg, thinPool
+ }
+
+ if vg == "" {
+ vg = genericVG
+ }
+
+ if thinPool == "" {
+ thinPool = genericThin
+ }
+
+ return vg, thinPool
+}
+
func newZFS(props map[string]string, exec storage.Exec, thin bool) (storage.Provider, error) {
// Upstream LINSTOR uses distinct prop keys per provider kind:
// `StorDriver/ZPool` for ZFS and `StorDriver/ZPoolThin` for
@@ -118,6 +167,13 @@ func newZFS(props map[string]string, exec storage.Exec, thin bool) (storage.Prov
// each kind (kind-specific key wins) to keep CRDs that were
// written before the rename and operators who copy-paste from
// LVM examples both working.
+ //
+ // A real LINSTOR ≥1.x database (and its k8s-backend dump) stores
+ // the zpool name under the generic `StorDriver/StorPoolName`
+ // instead, leaving both ZPool keys blank — so an adopted ZFS pool
+ // carries ONLY StorPoolName. Accept it as the final fallback,
+ // otherwise a migrated ZFS pool never registers a provider and no
+ // diskful resource on it can be served.
primary, secondary := propZPool, propZPoolThin
if thin {
primary, secondary = propZPoolThin, propZPool
@@ -128,13 +184,17 @@ func newZFS(props map[string]string, exec storage.Exec, thin bool) (storage.Prov
pool = props[secondary]
}
+ if pool == "" {
+ pool = props[propStorPoolName]
+ }
+
if pool == "" {
kind := ProviderKindZFS
if thin {
kind = ProviderKindZFSThin
}
- return nil, errors.Errorf("%s provider requires %q in props", kind, primary)
+ return nil, errors.Errorf("%s provider requires %q, %q or %q in props", kind, primary, secondary, propStorPoolName)
}
return zfs.NewProvider(zfs.Config{Pool: pool, Thin: thin}, exec), nil
diff --git a/pkg/satellite/factory_test.go b/pkg/satellite/factory_test.go
index befe9978..58cf6133 100644
--- a/pkg/satellite/factory_test.go
+++ b/pkg/satellite/factory_test.go
@@ -126,6 +126,102 @@ func TestFactoryZFSFallsBackBetweenKeys(t *testing.T) {
assertZFSProviderUsesPool(t, provThin, thinExec, "zthick")
}
+// TestFactoryZFSAdoptsStorPoolNameKey pins B1: a real LINSTOR ≥1.x
+// database stores the zpool name under the generic
+// `StorDriver/StorPoolName` with BOTH ZPool keys blank (verified
+// against a production ZFS-backed cluster: `sp l` reports
+// StorPoolName=data with ZPool/ZPoolThin empty, and the node's zpool is
+// named `data`). Before the fallback, NewProviderFromKind
+// errored on these exact props → the pool never registered → NO
+// diskful resource could adopt. This test fails on the pre-fix factory
+// and passes with the StorPoolName fallback.
+func TestFactoryZFSAdoptsStorPoolNameKey(t *testing.T) {
+ t.Parallel()
+
+ prodProps := map[string]string{
+ "StorDriver/StorPoolName": "data",
+ "StorDriver/internal/AllocationGranularity": "16",
+ "StorDriver/internal/optIoSize": "33554432",
+ "StorDriver/internal/minIoSize": "4096",
+ }
+
+ thickExec := storage.NewFakeExec()
+
+ provThick, err := satellite.NewProviderFromKind(satellite.ProviderKindZFS, prodProps, thickExec)
+ if err != nil {
+ t.Fatalf("NewProviderFromKind(ZFS, StorPoolName only): %v", err)
+ }
+
+ if provThick == nil {
+ t.Fatalf("NewProviderFromKind(ZFS, StorPoolName only) returned nil provider")
+ }
+
+ assertZFSProviderUsesPool(t, provThick, thickExec, "data")
+
+ thinExec := storage.NewFakeExec()
+
+ provThin, err := satellite.NewProviderFromKind(satellite.ProviderKindZFSThin, prodProps, thinExec)
+ if err != nil {
+ t.Fatalf("NewProviderFromKind(ZFS_THIN, StorPoolName only): %v", err)
+ }
+
+ if provThin == nil {
+ t.Fatalf("NewProviderFromKind(ZFS_THIN, StorPoolName only) returned nil provider")
+ }
+
+ assertZFSProviderUsesPool(t, provThin, thinExec, "data")
+}
+
+// TestFactoryLVMAdoptsStorPoolNameKey pins the same generic-key gap for
+// LVM and LVM_THIN that the ZFS test pins: LINSTOR records the backing
+// volume group (LVM) or `/` (LVM_THIN) under the generic
+// `StorDriver/StorPoolName`, leaving `StorDriver/LvmVg` and
+// `StorDriver/ThinPool` blank. Without the fallback an adopted
+// LVM-backed pool never registers a provider — the same total-adoption
+// failure the ZFS fix addressed, and it would only surface at cutover,
+// after the source controller is already stopped.
+func TestFactoryLVMAdoptsStorPoolNameKey(t *testing.T) {
+ t.Parallel()
+
+ thick, err := satellite.NewProviderFromKind(
+ satellite.ProviderKindLVM,
+ map[string]string{"StorDriver/StorPoolName": "vg0"},
+ storage.NewFakeExec(),
+ )
+ if err != nil {
+ t.Fatalf("NewProviderFromKind(LVM, StorPoolName only): %v", err)
+ }
+
+ if thick == nil || thick.Kind() != "LVM" {
+ t.Fatalf("LVM provider not registered from the generic key: %v", thick)
+ }
+
+ // LVM_THIN records both halves in one generic value.
+ thin, err := satellite.NewProviderFromKind(
+ satellite.ProviderKindLVMThin,
+ map[string]string{"StorDriver/StorPoolName": "vg0/thinpool"},
+ storage.NewFakeExec(),
+ )
+ if err != nil {
+ t.Fatalf("NewProviderFromKind(LVM_THIN, StorPoolName only): %v", err)
+ }
+
+ if thin == nil || thin.Kind() != "LVM_THIN" {
+ t.Fatalf("LVM_THIN provider not registered from the generic key: %v", thin)
+ }
+
+ // A generic value carrying only the VG still needs the thin pool:
+ // fail loudly rather than guess a thin-pool name.
+ _, err = satellite.NewProviderFromKind(
+ satellite.ProviderKindLVMThin,
+ map[string]string{"StorDriver/StorPoolName": "vg0"},
+ storage.NewFakeExec(),
+ )
+ if err == nil {
+ t.Error("LVM_THIN with no thin pool in either key must error, not guess")
+ }
+}
+
// TestFactoryZFSMissingBothKeysErrors documents the negative
// path: when neither key is present the factory must surface a
// readable error mentioning the canonical (primary) key so
diff --git a/pkg/store/k8s/drbd_transcode_export.go b/pkg/store/k8s/drbd_transcode_export.go
new file mode 100644
index 00000000..48bf5d0e
--- /dev/null
+++ b/pkg/store/k8s/drbd_transcode_export.go
@@ -0,0 +1,43 @@
+// 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.
+*/
+
+package k8s
+
+import (
+ crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
+)
+
+// SplitProps is the exported face of the store's wire→CRD property
+// split for external writers that build CRDs directly (the
+// linstor-migrate converter). It applies exactly the same three-way
+// routing the REST write path uses:
+//
+// - recognised `DrbdOptions/...` keys → the typed DRBDOptions struct;
+// - unrecognised `DrbdOptions/...` keys → the extraProps overflow map;
+// - everything else (`Aux/*`, `StorDriver/*`, `StorPoolName`, ...) →
+// the residual props map, verbatim.
+//
+// Keeping direct CRD writers on this helper guarantees their objects
+// read back through the store's mergeProps exactly like REST-created
+// ones.
+func SplitProps(props map[string]string) (*crdv1alpha1.DRBDOptions, map[string]string, map[string]string) {
+ typed, extra := propsToTyped(props)
+ residual := stripDRBDProps(props)
+
+ return typed, residual, extra
+}