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 +}