Skip to content

feat(migrate): LINSTOR→blockstor migration converter + adoption-safety fixes#180

Open
Andrei Kvapil (kvaps) wants to merge 16 commits into
mainfrom
feat/linstor-migrate
Open

feat(migrate): LINSTOR→blockstor migration converter + adoption-safety fixes#180
Andrei Kvapil (kvaps) wants to merge 16 commits into
mainfrom
feat/linstor-migrate

Conversation

@kvaps

@kvaps Andrei Kvapil (kvaps) commented Jul 16, 2026

Copy link
Copy Markdown
Member

What this PR does

Adds linstor-migrate, an offline tool that converts a LINSTOR k8s-backend database dump into blockstor blockstor.cozystack.io/v1alpha1 CRD manifests, so an existing LINSTOR deployment can be adopted by blockstor in place — no data is copied or resynced, blockstor takes over the same on-disk zvols/LVs and running DRBD devices. It also fixes three adoption-safety gaps in blockstor core that the migration surfaced.

Why

The input is the set of *.internal.linstor.linbit.com CRDs LINSTOR uses as its SQL tables when running with the k8s connector. cmd/linstor-migrate -in <dump-dir> emits a deterministic multi-document YAML stream (nodes → storage pools → resource groups → resource definitions → resources → snapshots) on stdout and a migration report on stderr; the manifests are then applied to a blockstor control plane that adopts the existing storage. The whole converter lives in pkg/linstormigrate/.

The converter preserves every identity adoption depends on — per-volume DRBD minors, per-replica node-ids, the resource TCP port, the DRBD shared-secret, storage-pool backing config and object names — and stamps the adoption-safety latches (RD.Spec.Initialized=true, per-replica skipInitialSync=false) so any replica added after migration performs a real sync instead of coming up falsely UpToDate. It never guesses: rows it cannot faithfully represent (LUKS passphrases, unknown flag bits, DELETE'd / failed-deployment objects, orphan replicas) are skipped and reported rather than fabricated.

Adoption-safety fixes in blockstor core

  • ZFS pools register from StorDriver/StorPoolName (pkg/satellite/factory.go). A real LINSTOR database stores the zpool name under the generic StorDriver/StorPoolName, leaving StorDriver/ZPool[Thin] blank. newZFS previously read only the ZPool keys, so an adopted ZFS pool never registered a provider and no diskful resource could come up. It now accepts StorDriver/StorPoolName as the final fallback.
  • Adopted snapshots are never re-taken (internal/controller/snapshot_controller.go, api/v1alpha1/snapshot_types.go). An imported Snapshot arrives with empty status; the reconciler's phase logic would otherwise read that as "not started" and drive suspend-io on every diskful peer to re-take a snapshot that already exists on disk. A blockstor.io/adopted annotation short-circuits orchestration and backfills the terminal per-node state instead.
  • Live DRBD ports are preserved on adoption (pkg/linstormigrate -drbd-ports). LINSTOR does not persist the DRBD listen port in its database, so adopting with a different port would move the connection endpoint under live I/O. The optional live-port map, captured from the running kernel, presets the ports so drbdadm adjust is a no-op on the endpoint.

Safety model

Data preservation rests on existing blockstor invariants, verified during review: the satellite gates create-md behind a per-volume drbdadm dump-md probe (never re-creates metadata over an adopted device), blkid-guards mkfs (never reformats a populated filesystem), honours an explicit ["STORAGE"] layer stack (no DRBD wrap on non-DRBD volumes), and the allocator is set-if-nil for the preset identities (CEL enforces initialized append-only and skipInitialSync/drbdNodeID settable-once).

Testing

  • Golden + behaviour tests over a synthetic dump fixture shaped from two production cluster dumps (all identifiers, secrets and ciphertexts fabricated), with the flag-decode calibration, tie-breaker topology, multi-volume non-contiguous minors, protocol-A, dual-primary and referential-integrity cases pinned.
  • Server-side CRD + CEL validation: an envtest harness applies every converted object against blockstor's real CRDs; run locally against both production dumps, all objects apply with zero schema/CEL rejections.
  • Satellite-runtime regression: the converted storage-pool props are run through the real NewProviderFromKind, and factory_test.go pins the StorPoolName registration (proven fail-on-bug).
  • The adopted-snapshot gate is pinned with an envtest spec asserting no suspend-io fires; the zvol-name adoption invariant (converted metadata.name byte-matches the on-disk dataset for clean pvc-<uuid> names) is pinned against a future hashing regression.

Operator preconditions (see docs/linstor-migration.md)

The runbook is the required procedure; the converter is one step of it. Before any production cutover: capture live DRBD ports (report must show zero port warnings), gate on cluster health at dump time, cross-check on-disk zvol names, rehearse the full adoption on staging, provision spec.encryption by hand for LUKS-encrypted volumes, and migrate the smaller cluster first.

Clean-room

The converter reads only the operator's own LINSTOR database dumps (data) and blockstor's own API types. It does not read, embed or derive from LINSTOR source code.

Add `linstor-migrate`, an offline converter from a LINSTOR k8s-backend database dump to blockstor CRDs for in-place adoption of an existing LINSTOR deployment, plus adoption-safety fixes (ZFS pools register from StorDriver/StorPoolName; adopted snapshots are never re-taken; live DRBD ports are preserved).

Summary by CodeRabbit

  • New Features

    • Added a migration tool that converts LINSTOR database dumps into Blockstor resource manifests.
    • Supports preserving storage, replication, DRBD, snapshot, and resource configuration during migration.
    • Added adoption support for existing snapshots and on-disk data without repeating snapshot orchestration.
    • Added migration reports, warnings, DRBD port mapping, and deterministic manifest output.
  • Documentation

    • Added a migration runbook covering preparation, rehearsal, cutover, verification, limitations, and rollback.
  • Bug Fixes

    • Improved ZFS pool adoption when using generic storage-pool configuration.

Andrei Kvapil (kvaps) and others added 15 commits July 15, 2026 20:11
pkg/linstormigrate reads the *.internal.linstor.linbit.com CRD dumps
(LINSTOR's k8s-connector database tables) into typed row structs —
the input side of the LINSTOR->blockstor migration converter.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Converts the loaded LINSTOR k8s-backend tables into blockstor
v1alpha1 manifests:

- Node/StoragePool/ResourceGroup/ResourceDefinition/Resource/Snapshot
  with names normalized through the store's Name() helper and the
  original spelling preserved in the blockstor.io/linstor-name
  annotation.
- Props routed through the store's own three-way split (typed
  DRBDOptions / Props / ExtraProps) via a new exported SplitProps
  shim, so migrated objects read back exactly like REST-created ones.
- Adoption safety: RD.Spec.Initialized=true and per-replica
  skipInitialSync=false so post-migration replicas always SyncTarget
  real data; per-volume DRBD minors, per-replica node-ids and the
  RD TCP port are carried verbatim (clusterIP-style presets).
- The per-RD DRBD shared-secret is carried as the
  DrbdOptions/Net/shared-secret prop the satellite already renders.
- Flag bitmasks are decoded via empirically calibrated bit values
  (dump vs live REST cross-reference); unknown bits and DELETE'd /
  FAILED_DEPLOYMENT rows are skipped and reported, never guessed.
- Deterministic multi-doc YAML in apply order on stdout or -out,
  migration report on stderr.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
The fixture models the table shapes observed in two production
LINSTOR k8s-backend dumps (layer joins, JSON-array-in-string columns,
flag bitmask populations, props instance paths) with fully fabricated
identifiers, IPs, secrets and ciphertexts.

Pinned behaviours, each proven fail-on-bug against the pre-fix
converter where applicable:

- TIE_BREAKER decode requires the DISKLESS bit: the witness keeps its
  flags even though LINSTOR writes no LAYER_STORAGE_VOLUMES row for
  diskless replicas (the original implementation lost TIE_BREAKER
  there), while a stale TIE_BREAKER bit on a diskful replica (an
  auto-diskful leftover seen in production) is dropped and reported.
- Adoption safety latches: every RD converts Initialized=true, every
  replica skipInitialSync=false.
- DRBD identity carried verbatim: per-volume minors, per-replica
  node-ids, the RD TCP port and the net shared-secret prop.
- Never-guess policy: DELETE'd RDs and FAILED_DEPLOYMENT / in-flight
  snapshots are skipped with report lines.
- Full manifest + report golden files pin the output stream.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
LAYER_LUKS_VOLUMES passphrases are encrypted with the LINSTOR master
key; decrypting them needs the operator's master passphrase and
LINSTOR's KDF. Until that lands, every LUKS-backed RD is converted
with its layer stack intact and a loud per-RD report line telling the
operator to provision spec.encryption manually before adopting.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
- ControllerConfig: cluster-wide /CTRL DrbdOptions/* (auto tiebreaker,
  verify-alg allow-list, auto-evict) now distill into the blockstor
  ControllerConfig singleton instead of being silently lost; LINSTOR
  runtime plumbing (NetCom ports, Cluster/LocalID) and master-key
  crypto material are excluded by construction, pinned by test.
- Multi-volume RDs (observed in production with NON-contiguous DRBD
  minors) are covered by fixture + test: each volume slot carries its
  own minor verbatim.
- Typed routing pinned for protocol A (resource group) and
  allow-two-primaries / RWX (RD).
- Suffixed DRBD layer instances (external metadata) and
  operator-created linstor remotes now warn instead of silently
  dropping; the auto-generated self-remote stays silent.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
A Snapshot CRD imported by a migration tool arrives with
SuspendIO=false/TakeSnapshot=false and an EMPTY Status. nextPhase's
false/false branch read that as 'orchestration not started yet' and
flipped 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 — one freeze per imported
snapshot across the whole cluster.

New blockstor.io/adopted annotation short-circuits Reconcile before
any orchestration: the controller backfills the terminal per-node
Ready=true state (CreateTimestamp from the optional
blockstor.io/adopted-created-at ms-epoch annotation, so 'linstor s l'
keeps the real snapshot age) and never touches the Spec flags. The
satellite is a no-op on such objects by existing phase gates.

linstor-migrate stamps both annotations on every converted snapshot,
using the newest per-node create_timestamp from the dump.

Proven fail-on-bug: with the gate disabled, the new envtest spec
records 'advancing orchestration phase suspendIO=true' on an adopted
Snapshot and fails; with the gate it passes 30/30.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
LINSTOR 1.33 stores port=-1 in its database for most RDs — the DRBD
listen port is assigned at runtime and not persisted, so the dump's
tcp_port is empty for nearly every resource while the kernel mesh is
really listening on a concrete port (7000-701x on the validated
cluster). If blockstor adopts with a DIFFERENT port, the satellite's
drbdadm adjust changes the connection endpoint under live I/O: a
reconnect blip, and on a marginal quorum a transient quorum loss.

New -drbd-ports <file> (ParseDRBDPorts) takes a '<rd-name> <port>'
map captured from the running kernel and presets it on every RD and
replica, so adoption's adjust is a no-op on the endpoint. The live
map wins over the dump; when a port is in NEITHER source the replicas
get nil (blockstor allocates a fresh port) and the gap is reported
once per RD so the operator can capture ports or accept the blip.

Tests pin: live map overrides dump, missing port → nil + single
report line, and the port-file parser incl. malformed-line rejection.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Cutover procedure the converter is only one step of: pre-flight
(health check, DB dump, live DRBD-port capture, pool snapshot),
convert, ordered control-plane swap (freeze CSI, stop linstor
controller, deploy blockstor, apply manifests, converge, repoint
CSI), per-object verification, control-plane-only rollback, and the
explicit known-limitations table (LUKS, ports, dropped flag bits,
placement).

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Boots envtest, installs blockstor's real CRDs and server-side-applies
every converted object, so the migrator's output is proven against
the actual OpenAPI schema AND every CEL XValidation rule (composite
<rd>.<node> / <pool>.<node> / <rd>.<snap> names, settable-once
drbdPort/drbdNodeID/initialized, enums) — a Go struct build cannot
catch a CEL violation, only the apiserver can.

The synthetic fixture always runs; the two production dumps are also
validated when present at /tmp/{infra,hidora} (or $LINSTOR_DUMP_DIRS),
skipped otherwise so their real secrets never enter the repo. Local
run: infra 254/254, hidora 2113/2113 objects applied with zero
validation errors.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
A replica whose parent RD was skipped (DELETE'd/absent) or whose host
node did not convert would previously emit an orphan Resource: its
<rd>.<node> CRD references an object that is never applied. Same for a
snapshot whose parent RD didn't convert. Adopting such a dangling
object risks the controller/satellite mishandling a resource with no
definition.

The converter now tracks the set of migrated RDs and nodes and skips
any replica/snapshot that would dangle, reporting each drop. The two
production dumps contain no such rows (verified), so their converted
object counts are unchanged and still pass server-side CRD validation
(254/254, 2113/2113). Fail-on-bug test: a fixture replica of a DELETE'd
RD must not convert.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Symmetry with the replica/snapshot referential-integrity guard: a
StoragePool on a node the converter skipped (e.g. a CONTROLLER-only
node) is dead config; drop and report it. No such pools exist in the
production dumps, so counts are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
BLOCKER found by the production release gate. A real LINSTOR >=1.x
database (and its k8s-backend dump) stores the zpool name under the
generic StorDriver/StorPoolName, leaving StorDriver/ZPool and
StorDriver/ZPoolThin blank. Verified on the live aenix-infra cluster:
`linstor sp l` shows StorPoolName=data, ZPool/ZPoolThin empty, and the
node's real zpool is named data.

blockstor's newZFS only read the two ZPool keys, so a migrated ZFS
StoragePool never registered a provider -> every diskful resource on
it failed the Providers[pool] lookup -> NOTHING adopted. It was a loud,
safe failure, but the migration could not succeed. The synthetic
migrator fixture masked it by using StorDriver/ZPoolThin (a key
blockstor already read), so the whole suite was green while prod was
broken.

Fix: newZFS accepts StorDriver/StorPoolName as the final fallback for
both ZFS and ZFS_THIN. Regressions:
- pkg/satellite/factory_test.go: the exact prod props register a
  provider (fails on the pre-fix factory).
- the migrator fixture is reshaped to the real prod pool (ZFS thick +
  StorPoolName, no ZPool key), and a new migration-layer test runs the
  converted StoragePool props through the real NewProviderFromKind so
  the mask cannot return.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Pre-flight now requires verifying blockstor's computed zvol names
(<zpool>/<rd-lowercased>_<vol%05d>) byte-match the on-disk datasets —
critical for single-replica STORAGE-only volumes, which have no DRBD
resync fallback if the name misses — plus a full staging rehearsal
before any production cutover.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Adoption is name-based: blockstor addresses each zvol as
<zpool>/<RD metadata.name>_<vol%05d> (zfs.go volumeDataset) and only
adopts an existing dataset. Every production RD name across both dumps
is a clean pvc-<uuid> (113/113 + 679/679), which lowercases to an
RFC-1123-clean name that k8sstore.Name passes through unchanged — so
the converted metadata.name byte-matches LINSTOR's on-disk
<zpool>/pvc-<uuid>_00000. The test pins that verbatim-lowercase
invariant (fixture RDs + a real uppercase UUID); a future change that
hashed a clean name would make single-replica STORAGE-only volumes
adopt an empty disk, and this catches it.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kvaps, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ccf4dfa-298a-4028-908a-b4dfd0e2eb59

📥 Commits

Reviewing files that changed from the base of the PR and between 50aa509 and a9c806a.

📒 Files selected for processing (9)
  • pkg/linstormigrate/convert.go
  • pkg/linstormigrate/convert_test.go
  • pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/golden/manifests.yaml
  • pkg/linstormigrate/testdata/golden/report.txt
📝 Walkthrough

Walkthrough

Adds a LINSTOR-to-Blockstor migration package and CLI, adopted Snapshot reconciliation, ZFS pool-name fallback handling, migration documentation, and comprehensive fixture, golden, unit, and envtest validation.

Changes

LINSTOR migration

Layer / File(s) Summary
Migration input contracts and indexes
pkg/linstormigrate/model.go, dump.go, props.go, flags.go, ports.go, pkg/store/k8s/drbd_transcode_export.go
Defines dump models, table loading, property access, flag decoding, DRBD port parsing, and property partitioning.
LINSTOR resource conversion
pkg/linstormigrate/convert.go, pkg/satellite/factory.go, pkg/satellite/factory_test.go
Converts LINSTOR records into Blockstor resources, preserves DRBD and storage metadata, emits adoption annotations and warnings, and accepts StorDriver/StorPoolName for ZFS providers.
Migration output and command workflow
pkg/linstormigrate/emit.go, cmd/linstor-migrate/main.go, Makefile, .golangci.yml, docs/linstor-migration.md
Adds deterministic manifest/report emission, CLI flags and output handling, build integration, lint configuration, and migration procedures.
Adopted Snapshot reconciliation
api/v1alpha1/snapshot_types.go, internal/controller/snapshot_controller.go, internal/controller/snapshot_adopted_test.go
Adds adopted Snapshot annotations and bypasses orchestration while backfilling terminal per-node status.
Conversion fixtures and validation
pkg/linstormigrate/convert_test.go, pkg/linstormigrate/validate_envtest_test.go, pkg/linstormigrate/testdata/*
Adds converter behavior tests, dump fixtures, golden manifests and reports, and CRD validation through envtest.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant linstor-migrate
  participant linstormigrate
  participant BlockstorAPI
  Operator->>linstor-migrate: Provide dump and optional DRBD ports
  linstor-migrate->>linstormigrate: Load and convert dump
  linstormigrate-->>linstor-migrate: Return manifests and warnings
  linstor-migrate->>BlockstorAPI: Apply generated manifests
  BlockstorAPI-->>Operator: Report validation and adoption state
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: a LINSTOR-to-blockstor migration converter plus adoption-safety fixes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/linstor-migrate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the linstor-migrate tool to convert LINSTOR database dumps into blockstor CRD manifests, enabling seamless adoption of existing on-disk data. It includes the core conversion logic, a command-line interface, a comprehensive runbook, and unit/integration tests. Additionally, the snapshot controller is updated to recognize adopted snapshots and bypass standard orchestration to prevent production I/O freezes, and a fallback is added to the ZFS provider factory to support StorDriver/StorPoolName for ZFS pool name resolution. Feedback points out a critical issue in the converter where reference fields are assigned raw, non-normalized names (often uppercase or mixed-case in LINSTOR) while the generated objects use normalized lowercase names, which would cause Kubernetes controllers to fail to resolve references.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +886 to +900
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In LINSTOR, resource, node, and resource group names are often uppercase or mixed-case (e.g., NODE-A, SC-REPLICATED, PVC-VOL1). While the converter normalizes the metadata.name of the generated objects to lowercase/RFC-1123 compliant names (via objectMeta which calls k8sstore.Name), the reference fields in other specs (such as StoragePoolSpec.NodeName, ResourceSpec.NodeName, ResourceSpec.ResourceDefinitionName, ResourceDefinitionSpec.ResourceGroupName, and SnapshotSpec.ResourceDefinitionName) are assigned the raw, non-normalized names returned by displayNode, displayRD, and displayRG.

This case mismatch will cause Kubernetes controllers to fail to resolve these references (e.g., looking for NODE-A instead of node-a), leading to NotFound errors and adoption failures in production environments where display names are not explicitly lowercase.

We can elegantly solve this across the entire converter by normalizing the output of displayNode, displayRD, and displayRG using k8sstore.Name.

Suggested change
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) displayNode(name string) string {
return k8sstore.Name(displayName(c.nodeDsp[name], name))
}
func (c *converter) displayRD(name string) string {
return k8sstore.Name(displayName(c.rdDsp[name], name))
}
func (c *converter) displayRG(name string) string {
if name == "" {
return ""
}
return k8sstore.Name(displayName(c.rgDsp[name], name))
}

@kvaps
Andrei Kvapil (kvaps) marked this pull request as ready for review July 16, 2026 10:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
internal/controller/snapshot_adopted_test.go (1)

61-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting unexpected errors and existing state in test setup.

Currently, if k8sClient.Get returns an unexpected error (e.g., connection failure) or if the object already exists (err == nil), the test silently skips creation. This could lead to confusing failures later in the test or flaky behavior from reusing stale state.

Consider explicitly asserting the expected NotFound error to ensure a clean slate.

💡 Proposed refactor
-			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())
-			}
+			err := k8sClient.Get(ctx, typeNamespacedName, existing)
+			Expect(err).To(HaveOccurred(), "Snapshot should not exist before test")
+			Expect(errors.IsNotFound(err)).To(BeTrue(), "Unexpected error getting Snapshot")
+
+			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())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/snapshot_adopted_test.go` around lines 61 - 79, Update
the setup around k8sClient.Get to explicitly require a NotFound result before
creating the Snapshot resource. Assert that err is non-nil and
errors.IsNotFound(err), and fail the test for unexpected errors or an
already-existing object instead of silently continuing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/linstormigrate/convert.go`:
- Around line 533-543: Validate the non-empty resource group reference before
constructing the ResourceDefinition in the conversion flow around SplitProps and
c.displayRG. Confirm the referenced group exists in the converted result, and
reject or report the record when it does not; only emit ResourceGroupName for
valid references while preserving handling of empty names.
- Around line 848-859: Update snapshotNodesFor to include only nodes confirmed
by convertedNode, excluding CONTROLLER, unknown, and skipped nodes; warn when a
snapshot placement is excluded. Return no snapshot entries when no valid nodes
remain, while preserving sorting of retained display nodes.

---

Nitpick comments:
In `@internal/controller/snapshot_adopted_test.go`:
- Around line 61-79: Update the setup around k8sClient.Get to explicitly require
a NotFound result before creating the Snapshot resource. Assert that err is
non-nil and errors.IsNotFound(err), and fail the test for unexpected errors or
an already-existing object instead of silently continuing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d83e718-6146-4528-8514-edfaf7be4760

📥 Commits

Reviewing files that changed from the base of the PR and between 1ca6fa6 and 50aa509.

📒 Files selected for processing (39)
  • .golangci.yml
  • Makefile
  • api/v1alpha1/snapshot_types.go
  • cmd/linstor-migrate/main.go
  • docs/linstor-migration.md
  • internal/controller/snapshot_adopted_test.go
  • internal/controller/snapshot_controller.go
  • pkg/linstormigrate/convert.go
  • pkg/linstormigrate/convert_test.go
  • pkg/linstormigrate/dump.go
  • pkg/linstormigrate/emit.go
  • pkg/linstormigrate/flags.go
  • pkg/linstormigrate/model.go
  • pkg/linstormigrate/ports.go
  • pkg/linstormigrate/props.go
  • pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerluksvolumes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/linstorremotes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/nodenetinterfaces.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/nodes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/volumegroups.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json
  • pkg/linstormigrate/testdata/golden/manifests.yaml
  • pkg/linstormigrate/testdata/golden/report.txt
  • pkg/linstormigrate/validate_envtest_test.go
  • pkg/satellite/factory.go
  • pkg/satellite/factory_test.go
  • pkg/store/k8s/drbd_transcode_export.go

Comment on lines +36 to +95
// 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())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add the required recorded-response and server byte-diff golden test.

The suite uses fabricated dump rows and compares generated bytes only with local files, so it cannot detect divergence from real golinstor output or blockstor server normalization/validation.

  • pkg/linstormigrate/convert_test.go#L36-L95: feed recorded golinstor responses into the converter and byte-diff the result returned by the blockstor server.
  • pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json#L1-L6: replace or supplement this synthetic table with recorded response data.
  • pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json#L1-L10: use the matching recorded table response.
  • pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json#L1-L48: use the matching recorded table response.
  • pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json#L1-L10: use the matching recorded table response.
  • pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json#L1-L6: use the matching recorded table response.
  • pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json#L1-L15: use the matching recorded table response.

As per coding guidelines, “L2 contract / golden tests must use recorded golinstor responses → blockstor server byte-diff validation.”

📍 Affects 7 files
  • pkg/linstormigrate/convert_test.go#L36-L95 (this comment)
  • pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json#L1-L6
  • pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json#L1-L10
  • pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json#L1-L48
  • pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json#L1-L10
  • pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json#L1-L6
  • pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json#L1-L15

Source: Coding guidelines

Comment thread pkg/linstormigrate/convert.go
Comment thread pkg/linstormigrate/convert.go Outdated
@IvanHunters

Copy link
Copy Markdown

Verdict

NOT LGTM

The converter and the snapshot gate hold up under inspection, but the StorPoolName adoption fix covers only ZFS while LVM/LVM_THIN pools from the same database shape fail to register, and the converter silently collapses per-volume storage-pool placement to volume 0's pool, which contradicts the PR's own "never guesses, skips-and-reports" contract in a path whose worst case is a silently empty disk.

Findings

[MAJOR] pkg/satellite/factory.go:90-111 — the StorDriver/StorPoolName fallback fixes ZFS only; adopted LVM and LVM_THIN pools from the same database shape never register a provider

The PR's own rationale for the ZFS fix ("a real LINSTOR database stores the pool name under the generic StorDriver/StorPoolName, leaving the kind-specific keys blank") applies to every provider kind: LINSTOR's storage layer reads the backing-device name for LVM ("vg") and LVM_THIN ("vg/thinpool") from the same generic StorDriver/StorPoolName key, and the legacy StorDriver/LvmVg / StorDriver/ThinPool keys are exactly as absent from a real dump as ZPool/ZPoolThin were. newLVMThick and newLVMThin still read only the legacy keys, so a migrated LVM-backed cluster hits the identical "provider requires ... in props" non-registration the PR fixed for ZFS — at cutover time, after the LINSTOR controller has already been scaled to zero. The runbook presents LVM as in scope ("Back up the ZFS/LVM pools", docs/linstor-migration.md:46) and nothing in the code or docs names the limitation. Fix: add the same fallback to newLVMThick (VG from StorPoolName) and newLVMThin (split "vg/thinpool"), with a factory_test.go case per kind mirroring TestFactoryZFSAdoptsStorPoolNameKey; alternatively, make the converter fail-loud on LVM pools whose props lack the legacy keys and document LVM as unsupported.

[MAJOR] pkg/linstormigrate/convert.go:703-707 — per-replica StoragePool is taken from volume 0 only; divergent per-volume pools are silently collapsed

buildResource sets Spec.StoragePool from the LAYER_STORAGE_VOLUMES row of vlmNr: 0 and never inspects the other volumes' rows, although the index (c.storVol, keyed by vlmNr) has them. LINSTOR supports per-volume storage pools (VD-level StorPoolName); a multi-volume RD with volume 1 in a different pool converts without any warning, and blockstor (whose Resource.Spec.StoragePool is a single field) will look for volume 1's backing device in the wrong pool. Downstream that means a fresh empty zvol is created next to the real one: recoverable via resync for multi-replica DRBD (an unexpected full sync, violating the runbook's "out-of-sync stays 0" gate), silently an empty disk for single-replica ["STORAGE"] volumes — the exact class the runbook's step 5 warns about for names. This is a "guess" in a data-bearing field. Fix: when any storVol row of the replica carries a StorPoolName differing from volume 0's (or volume 0's row is absent while others exist), warn and skip the replica, consistent with the referential-integrity skips at convert.go:655-665.

[MINOR] pkg/linstormigrate/model.go:38, pkg/linstormigrate/dump.go:101NODES.node_flags and the entire VOLUMES table are loaded but never examined; DELETE/EVICTED markers on those objects convert silently

convertNodes (convert.go:353-398) reads only node_type; a node flagged DELETE or EVICTED converts as a live node with no report, and dump.Volumes (per-replica volume rows with their own vlm_flags) is never read at all. This is inconsistent with the DELETE handling every other kind gets (RD convert.go:523, VD convert.go:609, resource convert.go:645, snapshot convert.go:735) and with the PR-body claim that DELETE'd objects are skipped and reported. Decode (or at least warn on) non-zero node_flags and VOLUMES.vlm_flags, or drop the unused Volumes/LayerDrbdVolumes loads and document why they carry no adoption-relevant state.

[MINOR] pkg/linstormigrate/convert.go:848-860 — snapshot Spec.Nodes is not filtered through convertedNode, unlike replicas

snapshotNodesFor copies every RESOURCES snapshot-placement row's node, including nodes that were skipped (CONTROLLER, unknown node_type). The controller-side backfill (internal/controller/snapshot_controller.go:190-198) then stamps Ready=true for a node blockstor does not know, so the object lists as fully present while a restore from that node cannot work. Apply the same convertedNode check used at convert.go:661 and report the dropped node.

[MINOR] internal/controller/snapshot_controller.go:113 — the adopted gate does not guard against the annotation arriving on an in-flight Snapshot; suspend-io stays frozen

If blockstor.io/adopted is added to a Snapshot that has already entered Phase 1 (Spec.SuspendIO=true, satellites acked), the gate short-circuits all orchestration: backfillAdoptedSnapshot sees existing per-node rows (hasNodeStatusEntry), changes nothing, and nothing ever flips SuspendIO back to false, so satellites hold drbdsetup suspend-io on every diskful peer indefinitely. The converter only stamps the annotation at object creation, so this needs operator misuse, but the failure mode is frozen production I/O with no error anywhere. Cheap defence: refuse the gate (log + fall through, or clear SuspendIO) when snap.Spec.SuspendIO is true, and say so in the AnnotationSnapshotAdopted godoc.

[MINOR] cmd/linstor-migrate/main.go:96,148-153 — output-file close failure is logged but the process still exits 0

closeOut runs deferred after run()'s return value is fixed; an ENOSPC-style flush failure on -out produces a truncated manifest file, an error line on stderr, and exit code 0. For a tool whose output is applied to live storage, a scripted linstor-migrate ... && kubectl apply ... pipeline treats the truncated stream as success. Close the file explicitly before returning and propagate the error into the exit code.

[MINOR] pkg/linstormigrate/convert_test.go:279, pkg/satellite/factory_test.go:132 — an internal cluster name leaked into public code comments

The PR body states all fixture identifiers are fabricated, yet both comments name the live production cluster the shape was taken from. Replace with a generic phrase ("a production ZFS-backed cluster").

Claim mismatches

[PARTIAL] "It never guesses: rows it cannot faithfully represent ... are skipped and reported" — three silent paths remain: per-volume storage-pool divergence collapsed to volume 0's pool (convert.go:703-707), NODES.node_flags and VOLUMES rows never examined (model.go:38, dump.go:101), snapshot Spec.Nodes retaining unmigrated nodes without a report (convert.go:848-860).

[PARTIAL] "ZFS pools register from StorDriver/StorPoolName" as the fix for adopted pools — the identical gap stays open for LVM/LVM_THIN (factory.go:90-111), which the same generic-key mechanism affects and which the runbook treats as in scope.

[UNVERIFIABLE] "run locally against both production dumps, all objects apply with zero schema/CEL rejections" — the production dumps are not in the repo; what is verifiable is the in-repo fixture passing the envtest CRD+CEL harness (TestConvertedManifestsPassCRDValidation, confirmed passing locally with KUBEBUILDER_ASSETS set).

Caveats

  • Verified by execution in a clone: go build ./... and go vet clean; go test ./pkg/linstormigrate/... ./pkg/satellite/... pass; envtest suites (pkg/linstormigrate CRD validation, internal/controller including the adopted-snapshot spec) pass with KUBEBUILDER_ASSETS set. Note the envtest harness self-skips when KUBEBUILDER_ASSETS is unset (validate_envtest_test.go:54), so a plain go test run does not exercise it.
  • Identity-preservation claims re-verified in source, not taken from the PR body: DRBD minors/node-ids carried verbatim (convert.go:301-350,623,696), shared-secret via DrbdOptions/Net/shared-secret (convert.go:552-564), live-port-wins resolution (convert.go:795-808, keys lowercased on both sides, ports.go:67), latches Initialized=true (convert.go:548) and SkipInitialSync=false (convert.go:699); CEL settable-once/append-only rules exist at api/v1alpha1/resource_types.go:199,212,250 and resourcedefinition_types.go:134; allocator fills only nil identities (internal/controller/resource_controller.go:744-753,1542-1575); create-md gated on first-activation detection (pkg/satellite/reconciler.go:2104-2116); explicit LayerStack honoured (resource_controller.go:2083-2101).
  • The NODES.node_type integer mapping is empirically calibrated with only type 2 (SATELLITE) confirmed against a live cluster (convert.go:50-59); a CONTROLLER/AUXILIARY ordering error would convert a controller node as AUXILIARY instead of skipping it. Impact is bounded (controller nodes carry no pools/replicas) but the assumption is unproven.
  • Adopted-snapshot usefulness (restore, delete via the satellite finalizer) rests on blockstor's on-disk snapshot name <pool>/<rd>_00000@<snap> (pkg/storage/zfs/zfs.go:901) byte-matching what LINSTOR left on disk; the runbook's pre-flight step 5 cross-checks zvol names but not snapshot names. A mismatch fails loudly at restore/delete time, after cutover.
  • Unhandled vlm_flags bits (golden report shows bit 32 dropped) are reported but not decoded; if such a bit alters size semantics (gross vs net), SizeKib is carried under the wrong interpretation. Reported, so operator-visible, but the runbook does not tell the operator what to do with that specific warning.
  • Non-RFC-1123 LINSTOR names would slug+hash through k8sstore.Name and then fail the CRD CEL name rules at apply time (loudly); TestZvolNameAdoptionInvariant pins only the clean pvc-<uuid> shape.

Recommended follow-ups

  • Add a snapshot-name cross-check to the runbook's pre-flight (mirror of step 5, for <dataset>@<snap>), so adopted-snapshot restore failures surface before cutover rather than after.
  • Decide and document the multi-volume story end to end: snapshotDataset hardcodes _00000 (zfs.go:901), so snapshots of multi-volume RDs only cover volume 0; pre-existing, but the converter now imports multi-volume snapshots into that limitation.

Addresses two referential-integrity gaps CodeRabbit flagged on #180,
consistent with the existing replica/pool guards:

- An RD naming a resource group that did not convert keeps the RD (a
  missing template does not make an adopted volume unusable) but clears
  the dangling reference and reports it, instead of emitting an RD that
  points at a non-existent RG.
- A snapshot placement on a node that did not convert (CONTROLLER /
  unknown / skipped) is dropped from Spec.Nodes with a warning; a
  snapshot left with no migrated node is skipped — otherwise the
  adopted Snapshot would wait for Ready from a Node object that never
  exists.

Also pins the cross-reference invariant Gemini flagged: every converted
Resource/Snapshot/RD reference resolves to another converted object's
metadata.name (assessed as a non-break for the production data — the
references carry the LINSTOR display name, which is what blockstor
stores on the wire and normalizes via Name() on lookup, and all prod
names are clean lowercase pvc-<uuid>; this test makes that guarantee
explicit).

Both production dumps convert to identical object counts and still pass
server-side CRD validation (254/254, 2113/2113).

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants