From 053f7028a422690abd5f88bfe787ef626260c263 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 20:11:13 +0200 Subject: [PATCH 01/18] feat(migrate): add LINSTOR k8s-backend dump loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Andrei Kvapil --- .golangci.yml | 6 + pkg/linstormigrate/dump.go | 120 +++++++++++++++++ pkg/linstormigrate/model.go | 249 ++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 pkg/linstormigrate/dump.go create mode 100644 pkg/linstormigrate/model.go 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/pkg/linstormigrate/dump.go b/pkg/linstormigrate/dump.go new file mode 100644 index 00000000..2ada90d4 --- /dev/null +++ b/pkg/linstormigrate/dump.go @@ -0,0 +1,120 @@ +// 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), + ) + 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/model.go b/pkg/linstormigrate/model.go new file mode 100644 index 00000000..89d243cc --- /dev/null +++ b/pkg/linstormigrate/model.go @@ -0,0 +1,249 @@ +// 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"` +} + +// 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 +} From d5cf4c216654fd24994a1d29b75bfd35af14f05b Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 20:46:19 +0200 Subject: [PATCH 02/18] feat(migrate): LINSTOR->blockstor CRD converter + linstor-migrate CLI 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 Signed-off-by: Andrei Kvapil --- Makefile | 1 + cmd/linstor-migrate/main.go | 127 +++++ pkg/linstormigrate/convert.go | 713 +++++++++++++++++++++++++ pkg/linstormigrate/emit.go | 129 +++++ pkg/linstormigrate/flags.go | 113 ++++ pkg/linstormigrate/props.go | 145 +++++ pkg/store/k8s/drbd_transcode_export.go | 43 ++ 7 files changed, 1271 insertions(+) create mode 100644 cmd/linstor-migrate/main.go create mode 100644 pkg/linstormigrate/convert.go create mode 100644 pkg/linstormigrate/emit.go create mode 100644 pkg/linstormigrate/flags.go create mode 100644 pkg/linstormigrate/props.go create mode 100644 pkg/store/k8s/drbd_transcode_export.go 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/cmd/linstor-migrate/main.go b/cmd/linstor-migrate/main.go new file mode 100644 index 00000000..235cfbab --- /dev/null +++ b/cmd/linstor-migrate/main.go @@ -0,0 +1,127 @@ +// 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)") + ) + + flag.Parse() + + if *inDir == "" { + fmt.Fprintln(os.Stderr, "error: -in is required") + flag.Usage() + + return 2 + } + + dump, err := linstormigrate.LoadDump(*inDir) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + + result, err := linstormigrate.Convert(dump) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + + out, closeOut, err := openOutput(*outPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + defer closeOut() + + err = linstormigrate.WriteManifests(out, result) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + + err = linstormigrate.WriteReport(os.Stderr, result) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + + return 0 +} + +// openOutput resolves the -out flag: "-" streams to stdout (no-op +// closer), anything else creates the file and closes it on exit. +func openOutput(path string) (io.Writer, func(), error) { + if path == "-" { + return os.Stdout, func() {}, nil + } + + file, err := os.Create(path) + if err != nil { + return nil, nil, fmt.Errorf("create %s: %w", path, err) + } + + closeFile := func() { + closeErr := file.Close() + if closeErr != nil { + fmt.Fprintf(os.Stderr, "error: close %s: %v\n", path, closeErr) + } + } + + return file, closeFile, nil +} diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go new file mode 100644 index 00000000..69225512 --- /dev/null +++ b/pkg/linstormigrate/convert.go @@ -0,0 +1,713 @@ +// 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" + +// 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 { + Nodes []crdv1alpha1.Node + StoragePools []crdv1alpha1.StoragePool + ResourceGroups []crdv1alpha1.ResourceGroup + ResourceDefinitions []crdv1alpha1.ResourceDefinition + Resources []crdv1alpha1.Resource + Snapshots []crdv1alpha1.Snapshot + Warnings []string +} + +// converter carries the indexes built once per Convert call. +type converter struct { + dump *Dump + 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 + + warnings []string +} + +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. +func Convert(dump *Dump) (*Result, error) { + conv := &converter{ + dump: dump, + 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{}, + } + + conv.buildIndexes() + + res := &Result{ + Nodes: conv.convertNodes(), + StoragePools: conv.convertStoragePools(), + ResourceGroups: conv.convertResourceGroups(), + ResourceDefinitions: conv.convertResourceDefinitions(), + Resources: conv.convertResources(), + Snapshots: conv.convertSnapshots(), + } + + res.Warnings = conv.warnings + + return res, nil +} + +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 == "" && row.ResourceNameSuffix == "" { + 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) + + 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, + }) + } + + 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 _, row := range c.dump.NodeStorPools { + nodeDsp := c.displayNode(row.NodeName) + poolDsp := displayName(c.poolDsp[row.PoolName], row.PoolName) + + 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) + } + } + + 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) + } + + typed, residual, extra := k8sstore.SplitProps(c.props.ResourceDefinition(row.ResourceName)) + + def := crdv1alpha1.ResourceDefinition{ + TypeMeta: typeMeta("ResourceDefinition"), + ObjectMeta: objectMeta(dsp), + Spec: crdv1alpha1.ResourceDefinitionSpec{ + ResourceGroupName: c.displayRG(row.ResourceGroupName), + 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), + }, + } + + if drbd, ok := c.drbdRD[row.ResourceName]; ok { + def.Spec.DRBDPort = drbd.TCPPort + + 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) + + defs = append(defs, def) + } + + sortByName(defs, func(d crdv1alpha1.ResourceDefinition) string { return d.Name }) + + return defs +} + +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 _, row := range c.dump.Resources { + 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 + } + + storVol, hasStorVol := c.storVol[volumeReplicaKey{node: row.NodeName, rd: row.ResourceName, vlmNr: 0}] + + flags, rest := decodeResourceFlags(row.ResourceFlags) + if rest != 0 { + c.warnf("resource %s: unhandled flags bits %d dropped (of %d)", replicaName, rest, row.ResourceFlags) + } + + 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 hasStorVol { + 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. + resource.Spec.DRBDPort = drbd.TCPPort + } + + resources = append(resources, resource) + } + + sortByName(resources, func(r crdv1alpha1.Resource) string { return r.Name }) + + return resources +} + +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 + } + + 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: c.snapshotNodesFor(row.ResourceName, row.SnapshotName), + }, + } + + for _, vd := range c.dump.VolumeDefinitions { + if vd.ResourceName != row.ResourceName || vd.SnapshotName != row.SnapshotName { + continue + } + + snap.Spec.VolumeDefinitions = append(snap.Spec.VolumeDefinitions, crdv1alpha1.SnapshotVolumeRef{ + VolumeNumber: vd.VlmNr, + SizeKib: vd.VlmSize, + }) + } + + sort.Slice(snap.Spec.VolumeDefinitions, func(i, j int) bool { + return snap.Spec.VolumeDefinitions[i].VolumeNumber < snap.Spec.VolumeDefinitions[j].VolumeNumber + }) + + snaps = append(snaps, snap) + } + + sortByName(snaps, func(s crdv1alpha1.Snapshot) string { return s.Name }) + + return snaps +} + +func (c *converter) snapshotNodesFor(rdName, snapName string) []string { + var out []string + + for _, row := range c.dump.Resources { + if row.ResourceName == rdName && row.SnapshotName == snapName { + 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...)) +} + +// 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/emit.go b/pkg/linstormigrate/emit.go new file mode 100644 index 00000000..84cb5ca9 --- /dev/null +++ b/pkg/linstormigrate/emit.go @@ -0,0 +1,129 @@ +// 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, + len(res.Nodes)+len(res.StoragePools)+len(res.ResourceGroups)+ + len(res.ResourceDefinitions)+len(res.Resources)+len(res.Snapshots)) + + 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/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/store/k8s/drbd_transcode_export.go b/pkg/store/k8s/drbd_transcode_export.go new file mode 100644 index 00000000..49d0c9d0 --- /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) (typed *crdv1alpha1.DRBDOptions, residual, extra map[string]string) { + typed, extra = propsToTyped(props) + residual = stripDRBDProps(props) + + return typed, residual, extra +} From f21affd79e0489398ce27436d74e68bea4f879b3 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 20:54:08 +0200 Subject: [PATCH 03/18] test(migrate): golden + calibration tests on a synthetic dump fixture 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert_test.go | 267 ++++++++++++++ ...finitions.internal.linstor.linbit.com.json | 6 + ...resources.internal.linstor.linbit.com.json | 9 + ...finitions.internal.linstor.linbit.com.json | 6 + ...ksvolumes.internal.linstor.linbit.com.json | 4 + ...sourceids.internal.linstor.linbit.com.json | 21 ++ ...gevolumes.internal.linstor.linbit.com.json | 10 + ...nterfaces.internal.linstor.linbit.com.json | 5 + .../nodes.internal.linstor.linbit.com.json | 5 + ...estorpool.internal.linstor.linbit.com.json | 5 + ...ontainers.internal.linstor.linbit.com.json | 21 ++ ...finitions.internal.linstor.linbit.com.json | 9 + ...rcegroups.internal.linstor.linbit.com.json | 5 + ...resources.internal.linstor.linbit.com.json | 14 + ...finitions.internal.linstor.linbit.com.json | 4 + ...finitions.internal.linstor.linbit.com.json | 9 + ...umegroups.internal.linstor.linbit.com.json | 4 + .../volumes.internal.linstor.linbit.com.json | 10 + .../testdata/golden/manifests.yaml | 330 ++++++++++++++++++ pkg/linstormigrate/testdata/golden/report.txt | 6 + 20 files changed, 750 insertions(+) create mode 100644 pkg/linstormigrate/convert_test.go create mode 100644 pkg/linstormigrate/testdata/dump/layerdrbdresourcedefinitions.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/layerdrbdresources.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/layerluksvolumes.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/nodenetinterfaces.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/nodes.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/resourcegroups.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/volumegroups.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json create mode 100644 pkg/linstormigrate/testdata/golden/manifests.yaml create mode 100644 pkg/linstormigrate/testdata/golden/report.txt diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go new file mode 100644 index 00000000..aa85da43 --- /dev/null +++ b/pkg/linstormigrate/convert_test.go @@ -0,0 +1,267 @@ +// 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" +) + +// 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) != 1 || vol1.Spec.VolumeDefinitions[0].DRBDMinor == nil || + *vol1.Spec.VolumeDefinitions[0].DRBDMinor != 1001 { + t.Errorf("pvc-vol1 volume 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) + } + } +} + +// 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) + } + + if !hasWarning(res, "snap-bad: FAILED_DEPLOYMENT") { + t.Errorf("failed snapshot skip not reported; 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/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..3e3681e7 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json @@ -0,0 +1,6 @@ +{"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}} +]} 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..1e74de49 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json @@ -0,0 +1,21 @@ +{"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"}} +]} 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..7079435c --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json @@ -0,0 +1,10 @@ +{"items":[ +{"spec":{"layer_resource_id":2,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":4,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":7,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":10,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":13,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":15,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":17,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, +{"spec":{"layer_resource_id":18,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}} +]} 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..9b2f6819 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json @@ -0,0 +1,5 @@ +{"items":[ +{"spec":{"driver_name":"ZFS_THIN","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_THIN","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"}} +]} 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..ac5b1be2 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json @@ -0,0 +1,21 @@ +{"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/ZPoolThin","prop_value":"data","props_instance":"/STOR_POOLS/NODE-A/DATA"}}, +{"spec":{"prop_key":"StorDriver/ZPoolThin","prop_value":"data","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"}} +]} 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..a200d14b --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json @@ -0,0 +1,9 @@ +{"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"}} +]} 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..1018b86f --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json @@ -0,0 +1,14 @@ +{"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"}} +]} 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..d2d51035 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json @@ -0,0 +1,4 @@ +{"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"}} +]} 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..ddd84606 --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json @@ -0,0 +1,9 @@ +{"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}} +]} 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..302c4d0d --- /dev/null +++ b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json @@ -0,0 +1,10 @@ +{"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}} +]} diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml new file mode 100644 index 00000000..896694e3 --- /dev/null +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -0,0 +1,330 @@ +--- +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/ZPoolThin: data + providerKind: ZFS_THIN +--- +apiVersion: blockstor.cozystack.io/v1alpha1 +kind: StoragePool +metadata: + name: data.node-b +spec: + nodeName: node-b + poolName: data + props: + StorDriver/ZPoolThin: data + providerKind: ZFS_THIN +--- +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: 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 +--- +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: 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-vol4.node-a +spec: + nodeName: node-a + resourceDefinitionName: pvc-vol4 + skipInitialSync: false +--- +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: Snapshot +metadata: + 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 diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt new file mode 100644 index 00000000..3e8c0741 --- /dev/null +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -0,0 +1,6 @@ +converted: 3 nodes, 3 storage pools, 3 resource groups, 4 resource definitions, 9 resources, 1 snapshots +warning: volume definition pvc-vol1/0: unhandled flags bitmask 32 dropped +warning: resource definition pvc-vol4: marked DELETE in the source cluster — skipped +warning: resource pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128) +warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048) +warning: snapshot pvc-vol1.snap-bad: FAILED_DEPLOYMENT in the source cluster — skipped From 08e485e34a0433776275265e967f679a3f504c71 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 21:03:07 +0200 Subject: [PATCH 04/18] feat(migrate): report non-migratable LUKS passphrases 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 22 ++++++++++++++ pkg/linstormigrate/convert_test.go | 30 +++++++++++++++++++ pkg/linstormigrate/testdata/golden/report.txt | 1 + 3 files changed, 53 insertions(+) diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 69225512..7a8fbc40 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -448,6 +448,16 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio 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) + } + defs = append(defs, def) } @@ -456,6 +466,18 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio return defs } +// 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 diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index aa85da43..b0e8aee1 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -256,6 +256,36 @@ func TestSkippedRows(t *testing.T) { } } +// 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) { diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index 3e8c0741..bbf69647 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -1,5 +1,6 @@ converted: 3 nodes, 3 storage pools, 3 resource groups, 4 resource definitions, 9 resources, 1 snapshots warning: volume definition pvc-vol1/0: unhandled flags bitmask 32 dropped +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 pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128) warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048) From 5c84809a03edc20e6c87184eb50cc13fda28d812 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 21:44:45 +0200 Subject: [PATCH 05/18] feat(migrate): close case-coverage gaps found in production dumps - 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 72 ++++++- pkg/linstormigrate/convert_test.go | 133 +++++++++++- pkg/linstormigrate/dump.go | 1 + pkg/linstormigrate/emit.go | 6 +- pkg/linstormigrate/model.go | 13 ++ ...finitions.internal.linstor.linbit.com.json | 54 ++++- ...gevolumes.internal.linstor.linbit.com.json | 104 +++++++++- ...orremotes.internal.linstor.linbit.com.json | 4 + ...ontainers.internal.linstor.linbit.com.json | 193 ++++++++++++++++-- ...finitions.internal.linstor.linbit.com.json | 93 ++++++++- .../volumes.internal.linstor.linbit.com.json | 114 ++++++++++- .../testdata/golden/manifests.yaml | 12 ++ pkg/linstormigrate/testdata/golden/report.txt | 1 + 13 files changed, 739 insertions(+), 61 deletions(-) create mode 100644 pkg/linstormigrate/testdata/dump/linstorremotes.internal.linstor.linbit.com.json diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 7a8fbc40..ce731074 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -76,6 +76,10 @@ func nodeTypeName(nodeType int32) string { // 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 @@ -141,6 +145,7 @@ func Convert(dump *Dump) (*Result, error) { conv.buildIndexes() res := &Result{ + ControllerConfig: conv.convertControllerConfig(), Nodes: conv.convertNodes(), StoragePools: conv.convertStoragePools(), ResourceGroups: conv.convertResourceGroups(), @@ -149,11 +154,62 @@ func Convert(dump *Dump) (*Result, error) { 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() @@ -186,9 +242,21 @@ func (c *converter) buildNameIndexes() { func (c *converter) buildLayerIndexes() { for i := range c.dump.LayerDrbdResourceDefinitions { row := &c.dump.LayerDrbdResourceDefinitions[i] - if row.SnapshotName == "" && row.ResourceNameSuffix == "" { - c.drbdRD[row.ResourceName] = *row + 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 { diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index b0e8aee1..2e09abb5 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -201,9 +201,9 @@ func TestDRBDIdentityCarriedVerbatim(t *testing.T) { t.Errorf("pvc-vol1 drbdPort = %v, want 7001", vol1.Spec.DRBDPort) } - if len(vol1.Spec.VolumeDefinitions) != 1 || vol1.Spec.VolumeDefinitions[0].DRBDMinor == nil || + if len(vol1.Spec.VolumeDefinitions) == 0 || vol1.Spec.VolumeDefinitions[0].DRBDMinor == nil || *vol1.Spec.VolumeDefinitions[0].DRBDMinor != 1001 { - t.Errorf("pvc-vol1 volume minor not carried: %+v", vol1.Spec.VolumeDefinitions) + t.Errorf("pvc-vol1 volume-0 minor not carried: %+v", vol1.Spec.VolumeDefinitions) } if vol1.Spec.ExtraProps[drbdSharedSecretProp] != "fixture-secret-vol1" { @@ -256,6 +256,135 @@ func TestSkippedRows(t *testing.T) { } } +// 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) + } +} + // 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 diff --git a/pkg/linstormigrate/dump.go b/pkg/linstormigrate/dump.go index 2ada90d4..bcd2b6f1 100644 --- a/pkg/linstormigrate/dump.go +++ b/pkg/linstormigrate/dump.go @@ -107,6 +107,7 @@ func LoadDump(dir string) (*Dump, error) { 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 diff --git a/pkg/linstormigrate/emit.go b/pkg/linstormigrate/emit.go index 84cb5ca9..1c063df8 100644 --- a/pkg/linstormigrate/emit.go +++ b/pkg/linstormigrate/emit.go @@ -33,9 +33,13 @@ import ( // output is deterministic and diffable between runs. func WriteManifests(w io.Writer, res *Result) error { docs := make([]any, 0, - len(res.Nodes)+len(res.StoragePools)+len(res.ResourceGroups)+ + 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]) } diff --git a/pkg/linstormigrate/model.go b/pkg/linstormigrate/model.go index 89d243cc..6ecfe97e 100644 --- a/pkg/linstormigrate/model.go +++ b/pkg/linstormigrate/model.go @@ -226,6 +226,18 @@ type LayerLuksVolumeRow struct { 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 @@ -246,4 +258,5 @@ type Dump struct { LayerDrbdVolumes []LayerDrbdVolumeRow LayerStorageVolumes []LayerStorageVolumeRow LayerLuksVolumes []LayerLuksVolumeRow + LinstorRemotes []LinstorRemoteRow } diff --git a/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json index 3e3681e7..d791bd13 100644 --- a/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerdrbdvolumedefinitions.internal.linstor.linbit.com.json @@ -1,6 +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}} -]} +{ +"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/layerstoragevolumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json index 7079435c..255f2902 100644 --- a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json @@ -1,10 +1,94 @@ -{"items":[ -{"spec":{"layer_resource_id":2,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":4,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":7,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":10,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":13,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":15,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":17,"node_name":"NODE-B","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}}, -{"spec":{"layer_resource_id":18,"node_name":"NODE-A","provider_kind":"ZFS_THIN","stor_pool_name":"DATA","vlm_nr":0}} -]} +{ +"items": [ +{ +"spec": { +"layer_resource_id": 2, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 4, +"node_name": "NODE-B", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 7, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 10, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 13, +"node_name": "NODE-B", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 15, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 17, +"node_name": "NODE-B", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 18, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 0 +} +}, +{ +"spec": { +"layer_resource_id": 2, +"node_name": "NODE-A", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"vlm_nr": 1 +} +}, +{ +"spec": { +"layer_resource_id": 4, +"node_name": "NODE-B", +"provider_kind": "ZFS_THIN", +"stor_pool_name": "DATA", +"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/propscontainers.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json index ac5b1be2..8a6bb6ba 100644 --- a/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json @@ -1,21 +1,172 @@ -{"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/ZPoolThin","prop_value":"data","props_instance":"/STOR_POOLS/NODE-A/DATA"}}, -{"spec":{"prop_key":"StorDriver/ZPoolThin","prop_value":"data","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"}} -]} +{ +"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/ZPoolThin", +"prop_value": "data", +"props_instance": "/STOR_POOLS/NODE-A/DATA" +} +}, +{ +"spec": { +"prop_key": "StorDriver/ZPoolThin", +"prop_value": "data", +"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" +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json index ddd84606..865480b1 100644 --- a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json @@ -1,9 +1,84 @@ -{"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}} -]} +{ +"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 +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json index 302c4d0d..5217e874 100644 --- a/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json @@ -1,10 +1,104 @@ -{"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}} -]} +{ +"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 +} +} +] +} diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index 896694e3..5673e278 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -1,5 +1,14 @@ --- 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 @@ -146,6 +155,9 @@ spec: DrbdCurrentGi: F1E2D3C4B5A69788 sizeKib: 1048576 volumeNumber: 0 + - drbdMinor: 1042 + sizeKib: 307200 + volumeNumber: 1 --- apiVersion: blockstor.cozystack.io/v1alpha1 kind: ResourceDefinition diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index bbf69647..ab2a7d2c 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -5,3 +5,4 @@ warning: resource definition pvc-vol4: marked DELETE in the source cluster — s warning: resource pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128) warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048) 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 From 56626e77dee52a3264419fb2a5f315dfe267bdc1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:18:15 +0200 Subject: [PATCH 06/18] =?UTF-8?q?feat(controller):=20adopted-snapshot=20ga?= =?UTF-8?q?te=20=E2=80=94=20never=20re-orchestrate=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Andrei Kvapil --- api/v1alpha1/snapshot_types.go | 25 ++++ internal/controller/snapshot_adopted_test.go | 132 ++++++++++++++++++ internal/controller/snapshot_controller.go | 105 ++++++++++++++ pkg/linstormigrate/convert.go | 66 +++++++-- pkg/linstormigrate/convert_test.go | 15 ++ .../testdata/golden/manifests.yaml | 3 + 6 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 internal/controller/snapshot_adopted_test.go diff --git a/api/v1alpha1/snapshot_types.go b/api/v1alpha1/snapshot_types.go index f07576d4..d5f90632 100644 --- a/api/v1alpha1/snapshot_types.go +++ b/api/v1alpha1/snapshot_types.go @@ -154,6 +154,31 @@ 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. +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/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..d0a55631 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,16 @@ 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. + if snap.Annotations[blockstoriov1alpha1.AnnotationSnapshotAdopted] == labelTrueValue { + 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 +154,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 index ce731074..84cfaa3c 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -44,6 +44,9 @@ const drbdSharedSecretProp = "DrbdOptions/Net/shared-secret" // 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 @@ -677,20 +680,23 @@ func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot { }, } - for _, vd := range c.dump.VolumeDefinitions { - if vd.ResourceName != row.ResourceName || vd.SnapshotName != row.SnapshotName { - continue - } + // 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. + if snap.Annotations == nil { + snap.Annotations = map[string]string{} + } - snap.Spec.VolumeDefinitions = append(snap.Spec.VolumeDefinitions, crdv1alpha1.SnapshotVolumeRef{ - VolumeNumber: vd.VlmNr, - SizeKib: vd.VlmSize, - }) + snap.Annotations[crdv1alpha1.AnnotationSnapshotAdopted] = annotationTrue + + if ts := c.snapshotCreatedAtFor(row.ResourceName, row.SnapshotName); ts > 0 { + snap.Annotations[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt] = strconv.FormatInt(ts, 10) } - sort.Slice(snap.Spec.VolumeDefinitions, func(i, j int) bool { - return snap.Spec.VolumeDefinitions[i].VolumeNumber < snap.Spec.VolumeDefinitions[j].VolumeNumber - }) + snap.Spec.VolumeDefinitions = c.snapshotVolumeRefsFor(row.ResourceName, row.SnapshotName) snaps = append(snaps, snap) } @@ -700,6 +706,44 @@ func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot { return snaps } +// 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 +} + func (c *converter) snapshotNodesFor(rdName, snapName string) []string { var out []string diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index 2e09abb5..3a1477ab 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -251,6 +251,21 @@ func TestSkippedRows(t *testing.T) { 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) } diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index 5673e278..5d8032ee 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -328,6 +328,9 @@ spec: 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: From d5aaff87aeab25e19462d2dd69b268e79392e293 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:30:37 +0200 Subject: [PATCH 07/18] feat(migrate): preserve live DRBD ports to avoid reconnect on adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (ParseDRBDPorts) takes a ' ' 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 Signed-off-by: Andrei Kvapil --- cmd/linstor-migrate/main.go | 35 +++++++- pkg/linstormigrate/convert.go | 76 ++++++++++++++-- pkg/linstormigrate/convert_test.go | 90 +++++++++++++++++++ pkg/linstormigrate/ports.go | 71 +++++++++++++++ pkg/linstormigrate/testdata/golden/report.txt | 2 + 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 pkg/linstormigrate/ports.go diff --git a/cmd/linstor-migrate/main.go b/cmd/linstor-migrate/main.go index 235cfbab..7b2bac1f 100644 --- a/cmd/linstor-migrate/main.go +++ b/cmd/linstor-migrate/main.go @@ -52,8 +52,9 @@ func main() { 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)") + 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() @@ -65,6 +66,13 @@ func run() int { return 2 } + opts, err := buildOptions(*portsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + + return 1 + } + dump, err := linstormigrate.LoadDump(*inDir) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) @@ -72,7 +80,7 @@ func run() int { return 1 } - result, err := linstormigrate.Convert(dump) + result, err := linstormigrate.ConvertWithOptions(dump, opts) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) @@ -104,6 +112,27 @@ func run() int { return 0 } +// 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 and closes it on exit. func openOutput(path string) (io.Writer, func(), error) { diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 84cfaa3c..1437e7f9 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -92,9 +92,27 @@ type Result struct { 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. @@ -110,7 +128,8 @@ type converter struct { storVol map[volumeReplicaKey]LayerStorageVolumeRow luksVol map[volumeReplicaKey]LayerLuksVolumeRow - warnings []string + warnings []string + warnedKey map[string]bool } type volumeKey struct { @@ -129,10 +148,18 @@ type volumeReplicaKey struct { vlmNr int32 } -// Convert translates a LINSTOR database dump into blockstor CRDs. +// 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{}, @@ -143,6 +170,7 @@ func Convert(dump *Dump) (*Result, error) { drbdNode: map[replicaKey]*int32{}, storVol: map[volumeReplicaKey]LayerStorageVolumeRow{}, luksVol: map[volumeReplicaKey]LayerLuksVolumeRow{}, + warnedKey: map[string]bool{}, } conv.buildIndexes() @@ -503,7 +531,7 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio } if drbd, ok := c.drbdRD[row.ResourceName]; ok { - def.Spec.DRBDPort = drbd.TCPPort + def.Spec.DRBDPort = c.resolveDRBDPort(row.ResourceName, drbd.TCPPort, dsp) if drbd.Secret != "" { if def.Spec.ExtraProps == nil { @@ -633,8 +661,10 @@ func (c *converter) convertResources() []crdv1alpha1.Resource { 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. - resource.Spec.DRBDPort = drbd.TCPPort + // 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) } resources = append(resources, resource) @@ -706,6 +736,30 @@ func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot { return snaps } +// 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 { @@ -802,6 +856,18 @@ 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 { diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index 3a1477ab..ffd86c06 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -226,6 +226,96 @@ func TestDRBDIdentityCarriedVerbatim(t *testing.T) { } } +// 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. 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/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index ab2a7d2c..be18608d 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -1,7 +1,9 @@ converted: 3 nodes, 3 storage pools, 3 resource groups, 4 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 pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128) warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048) warning: snapshot pvc-vol1.snap-bad: FAILED_DEPLOYMENT in the source cluster — skipped From 1af5424610393135597885003abe4610fb121ad6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:32:31 +0200 Subject: [PATCH 08/18] docs(migrate): LINSTOR to blockstor migration runbook 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 Signed-off-by: Andrei Kvapil --- docs/linstor-migration.md | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/linstor-migration.md diff --git a/docs/linstor-migration.md b/docs/linstor-migration.md new file mode 100644 index 00000000..9e8ce587 --- /dev/null +++ b/docs/linstor-migration.md @@ -0,0 +1,97 @@ +# 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. + +## 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 | From 0eef0c4e1fd8378a3e46368ed3f872942a29e7c9 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:35:27 +0200 Subject: [PATCH 09/18] test(migrate): server-side CRD+CEL validation of converted output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . / . / . 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/validate_envtest_test.go | 196 ++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 pkg/linstormigrate/validate_envtest_test.go 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{} From 1e903436981a4fb49840bc6c178301e6b36fd9ad Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:37:25 +0200 Subject: [PATCH 10/18] style(store): drop named returns from SplitProps (nonamedreturns lint) Co-authored-by: Claude Signed-off-by: Andrei Kvapil --- pkg/store/k8s/drbd_transcode_export.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/store/k8s/drbd_transcode_export.go b/pkg/store/k8s/drbd_transcode_export.go index 49d0c9d0..48bf5d0e 100644 --- a/pkg/store/k8s/drbd_transcode_export.go +++ b/pkg/store/k8s/drbd_transcode_export.go @@ -35,9 +35,9 @@ import ( // 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) (typed *crdv1alpha1.DRBDOptions, residual, extra map[string]string) { - typed, extra = propsToTyped(props) - residual = stripDRBDProps(props) +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 } From 72a654d332dfbb3ceb5465134146ecc9ff5284a9 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:45:21 +0200 Subject: [PATCH 11/18] feat(migrate): drop dangling replicas/snapshots (referential integrity) A replica whose parent RD was skipped (DELETE'd/absent) or whose host node did not convert would previously emit an orphan Resource: its . 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 138 +++++++++++------- pkg/linstormigrate/convert_test.go | 24 +++ .../testdata/golden/manifests.yaml | 9 -- pkg/linstormigrate/testdata/golden/report.txt | 3 +- 4 files changed, 114 insertions(+), 60 deletions(-) diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 1437e7f9..a1e75eaf 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -128,6 +128,12 @@ type converter struct { 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) + warnings []string warnedKey map[string]bool } @@ -158,19 +164,21 @@ func Convert(dump *Dump) (*Result, error) { // 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{}, - warnedKey: map[string]bool{}, + 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{}, + warnedKey: map[string]bool{}, } conv.buildIndexes() @@ -379,6 +387,8 @@ func (c *converter) convertNodes() []crdv1alpha1.Node { }) } + c.convertedNode[row.NodeName] = true + nodes = append(nodes, node) } @@ -557,6 +567,8 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio c.warnf("resource definition %s: LUKS passphrase NOT migrated (encrypted with the LINSTOR master key) — provision spec.encryption manually before adopting", dsp) } + c.convertedRD[row.ResourceName] = true + defs = append(defs, def) } @@ -611,7 +623,8 @@ func (c *converter) volumeDefinitionsFor(rdName, rdDsp string) []crdv1alpha1.Res func (c *converter) convertResources() []crdv1alpha1.Resource { resources := make([]crdv1alpha1.Resource, 0, len(c.dump.Resources)) - for _, row := range c.dump.Resources { + for i := range c.dump.Resources { + row := &c.dump.Resources[i] if row.SnapshotName != "" { continue // snapshot placement rows feed convertSnapshots } @@ -626,48 +639,23 @@ func (c *converter) convertResources() []crdv1alpha1.Resource { continue } - storVol, hasStorVol := c.storVol[volumeReplicaKey{node: row.NodeName, rd: row.ResourceName, vlmNr: 0}] + // 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) - flags, rest := decodeResourceFlags(row.ResourceFlags) - if rest != 0 { - c.warnf("resource %s: unhandled flags bits %d dropped (of %d)", replicaName, rest, row.ResourceFlags) - } - - 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), - }, + continue } - if hasStorVol { - resource.Spec.StoragePool = displayName(c.poolDsp[storVol.StorPoolName], storVol.StorPoolName) - } else if sp := resource.Spec.Props["StorPoolName"]; sp != "" { - resource.Spec.StoragePool = sp - } + if !c.convertedNode[row.NodeName] { + c.warnf("resource %s: host node %s was not migrated — replica skipped", replicaName, nodeDsp) - 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) + continue } - resources = append(resources, resource) + resources = append(resources, c.buildResource(row, rdDsp, nodeDsp, replicaName)) } sortByName(resources, func(r crdv1alpha1.Resource) string { return r.Name }) @@ -675,6 +663,52 @@ func (c *converter) convertResources() []crdv1alpha1.Resource { return resources } +// 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) + } + + 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 @@ -696,6 +730,10 @@ func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot { 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 } diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index ffd86c06..83e47fae 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -490,6 +490,30 @@ func TestRemoteWarning(t *testing.T) { } } +// 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) + } +} + // 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 diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index 5d8032ee..c4930cb3 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -296,15 +296,6 @@ spec: --- apiVersion: blockstor.cozystack.io/v1alpha1 kind: Resource -metadata: - name: pvc-vol4.node-a -spec: - nodeName: node-a - resourceDefinitionName: pvc-vol4 - skipInitialSync: false ---- -apiVersion: blockstor.cozystack.io/v1alpha1 -kind: Resource metadata: name: pvc-vol5.node-a spec: diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index be18608d..635200cf 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -1,10 +1,11 @@ -converted: 3 nodes, 3 storage pools, 3 resource groups, 4 resource definitions, 9 resources, 1 snapshots +converted: 3 nodes, 3 storage pools, 3 resource groups, 4 resource definitions, 8 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 pvc-vol1.node-b: unhandled flags bits 128 dropped (of 128) +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: 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 From 9504145d57e390997697ec4751fb62e3f395da99 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 22:47:12 +0200 Subject: [PATCH 12/18] feat(migrate): drop storage pools on un-migrated nodes 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index a1e75eaf..7a7b9fde 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -414,10 +414,19 @@ func (c *converter) netInterfacesFor(nodeName string) []NodeNetInterfaceRow { func (c *converter) convertStoragePools() []crdv1alpha1.StoragePool { pools := make([]crdv1alpha1.StoragePool, 0, len(c.dump.NodeStorPools)) - for _, row := range 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)), From 3db40679b6a8b949bc72b071a0fa0171ad2532f6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 23:06:29 +0200 Subject: [PATCH 13/18] fix(satellite): register ZFS pools from StorDriver/StorPoolName (B1) 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert_test.go | 42 +++++++++++++++++ ...gevolumes.internal.linstor.linbit.com.json | 20 ++++----- ...estorpool.internal.linstor.linbit.com.json | 42 ++++++++++++++--- ...ontainers.internal.linstor.linbit.com.json | 18 +++++++- .../testdata/golden/manifests.yaml | 10 +++-- pkg/satellite/factory.go | 24 +++++++--- pkg/satellite/factory_test.go | 45 +++++++++++++++++++ 7 files changed, 174 insertions(+), 27 deletions(-) diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index 83e47fae..f4a83dc9 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -28,6 +28,8 @@ import ( "testing" crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/satellite" + "github.com/cozystack/blockstor/pkg/storage" ) // The fixture under testdata/dump is a synthetic LINSTOR k8s-backend @@ -226,6 +228,46 @@ func TestDRBDIdentityCarriedVerbatim(t *testing.T) { } } +// 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 the live aenix-infra +// 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 diff --git a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json index 255f2902..a30e34ae 100644 --- a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json @@ -4,7 +4,7 @@ "spec": { "layer_resource_id": 2, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -13,7 +13,7 @@ "spec": { "layer_resource_id": 4, "node_name": "NODE-B", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -22,7 +22,7 @@ "spec": { "layer_resource_id": 7, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -31,7 +31,7 @@ "spec": { "layer_resource_id": 10, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -40,7 +40,7 @@ "spec": { "layer_resource_id": 13, "node_name": "NODE-B", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -49,7 +49,7 @@ "spec": { "layer_resource_id": 15, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -58,7 +58,7 @@ "spec": { "layer_resource_id": 17, "node_name": "NODE-B", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -67,7 +67,7 @@ "spec": { "layer_resource_id": 18, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 0 } @@ -76,7 +76,7 @@ "spec": { "layer_resource_id": 2, "node_name": "NODE-A", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 1 } @@ -85,7 +85,7 @@ "spec": { "layer_resource_id": 4, "node_name": "NODE-B", -"provider_kind": "ZFS_THIN", +"provider_kind": "ZFS", "stor_pool_name": "DATA", "vlm_nr": 1 } diff --git a/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json index 9b2f6819..271ff33d 100644 --- a/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json @@ -1,5 +1,37 @@ -{"items":[ -{"spec":{"driver_name":"ZFS_THIN","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_THIN","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"}} -]} +{ +"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" +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json index 8a6bb6ba..cbe9f044 100644 --- a/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json @@ -16,20 +16,34 @@ }, { "spec": { -"prop_key": "StorDriver/ZPoolThin", +"prop_key": "StorDriver/StorPoolName", "prop_value": "data", "props_instance": "/STOR_POOLS/NODE-A/DATA" } }, { "spec": { -"prop_key": "StorDriver/ZPoolThin", +"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" diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index c4930cb3..66b552a6 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -55,8 +55,9 @@ spec: nodeName: node-a poolName: data props: - StorDriver/ZPoolThin: data - providerKind: ZFS_THIN + StorDriver/StorPoolName: data + StorDriver/internal/AllocationGranularity: "16" + providerKind: ZFS --- apiVersion: blockstor.cozystack.io/v1alpha1 kind: StoragePool @@ -66,8 +67,9 @@ spec: nodeName: node-b poolName: data props: - StorDriver/ZPoolThin: data - providerKind: ZFS_THIN + StorDriver/StorPoolName: data + StorDriver/internal/AllocationGranularity: "16" + providerKind: ZFS --- apiVersion: blockstor.cozystack.io/v1alpha1 kind: StoragePool diff --git a/pkg/satellite/factory.go b/pkg/satellite/factory.go index a5486e70..2ad6ce26 100644 --- a/pkg/satellite/factory.go +++ b/pkg/satellite/factory.go @@ -44,11 +44,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` @@ -118,6 +119,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 +136,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..5d5d8bc4 100644 --- a/pkg/satellite/factory_test.go +++ b/pkg/satellite/factory_test.go @@ -126,6 +126,51 @@ 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 on +// the live aenix-infra cluster: sp l shows StorPoolName=data, +// ZPool/ZPoolThin empty). 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") +} + // TestFactoryZFSMissingBothKeysErrors documents the negative // path: when neither key is present the factory must surface a // readable error mentioning the canonical (primary) key so From f124d2ecd90817a8037416f15ab43433d8233fcb Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 23:07:12 +0200 Subject: [PATCH 14/18] docs(migrate): add zvol-name cross-check + staging rehearsal (gate B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight now requires verifying blockstor's computed zvol names (/_) 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 Signed-off-by: Andrei Kvapil --- docs/linstor-migration.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/linstor-migration.md b/docs/linstor-migration.md index 9e8ce587..50d9f46c 100644 --- a/docs/linstor-migration.md +++ b/docs/linstor-migration.md @@ -45,6 +45,21 @@ Migrated: nodes, storage pools, resource groups, resource definitions (+ volume 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). + +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 @@ -95,3 +110,4 @@ Until CSI is repointed and confirmed, rollback is: scale blockstor to 0, scale ` | 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) | From 50aa5092fafc5160ca5a4fbf19dc7ea824e8d7c6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 15 Jul 2026 23:12:05 +0200 Subject: [PATCH 15/18] test(migrate): pin zvol-name adoption invariant (gate B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption is name-based: blockstor addresses each zvol as /_ (zfs.go volumeDataset) and only adopts an existing dataset. Every production RD name across both dumps is a clean pvc- (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 /pvc-_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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert_test.go | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index f4a83dc9..22e4fb2b 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -30,6 +30,7 @@ import ( 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 @@ -228,6 +229,51 @@ func TestDRBDIdentityCarriedVerbatim(t *testing.T) { } } +// 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 the live aenix-infra From a9c806a0e18c2def75e0158e577e9526990b1bce Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 16 Jul 2026 19:04:08 +0200 Subject: [PATCH 16/18] feat(migrate): guard dangling RG refs + snapshot placements (PR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-; 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 Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 159 +++++++---- pkg/linstormigrate/convert_test.go | 103 +++++++ ...sourceids.internal.linstor.linbit.com.json | 254 ++++++++++++++++-- ...gevolumes.internal.linstor.linbit.com.json | 9 + ...finitions.internal.linstor.linbit.com.json | 111 +++++++- ...resources.internal.linstor.linbit.com.json | 148 +++++++++- ...finitions.internal.linstor.linbit.com.json | 10 + .../testdata/golden/manifests.yaml | 22 ++ pkg/linstormigrate/testdata/golden/report.txt | 3 +- 9 files changed, 717 insertions(+), 102 deletions(-) diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 7a7b9fde..3a67b47b 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -133,6 +133,7 @@ type converter struct { // 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 @@ -178,6 +179,7 @@ func ConvertWithOptions(dump *Dump, opts Options) (*Result, error) { luksVol: map[volumeReplicaKey]LayerLuksVolumeRow{}, convertedRD: map[string]bool{}, convertedNode: map[string]bool{}, + convertedRG: map[string]bool{}, warnedKey: map[string]bool{}, } @@ -487,6 +489,8 @@ func (c *converter) convertResourceGroups() []crdv1alpha1.ResourceGroup { } } + c.convertedRG[row.ResourceGroupName] = true + groups = append(groups, group) } @@ -530,13 +534,26 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio 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: c.displayRG(row.ResourceGroupName), + ResourceGroupName: rgRef, Props: residual, DRBDOptions: typed, ExtraProps: extra, @@ -549,32 +566,7 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio }, } - 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) - } + c.attachRDLayerFields(&def, row, dsp) c.convertedRD[row.ResourceName] = true @@ -586,6 +578,37 @@ func (c *converter) convertResourceDefinitions() []crdv1alpha1.ResourceDefinitio 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 { @@ -746,41 +769,49 @@ func (c *converter) convertSnapshots() []crdv1alpha1.Snapshot { continue } - 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: c.snapshotNodesFor(row.ResourceName, row.SnapshotName), - }, - } + nodes := c.snapshotNodesFor(row.ResourceName, row.SnapshotName, snapDsp) + if len(nodes) == 0 { + c.warnf("snapshot %s: no placement on a migrated node — skipped", name) - // 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. - if snap.Annotations == nil { - snap.Annotations = map[string]string{} + continue } - snap.Annotations[crdv1alpha1.AnnotationSnapshotAdopted] = annotationTrue + snaps = append(snaps, c.buildSnapshot(row, rdDsp, snapDsp, name, nodes)) + } - if ts := c.snapshotCreatedAtFor(row.ResourceName, row.SnapshotName); ts > 0 { - snap.Annotations[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt] = strconv.FormatInt(ts, 10) - } + sortByName(snaps, func(s crdv1alpha1.Snapshot) string { return s.Name }) - snap.Spec.VolumeDefinitions = c.snapshotVolumeRefsFor(row.ResourceName, row.SnapshotName) + return snaps +} - snaps = append(snaps, snap) +// 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, + }, } - sortByName(snaps, func(s crdv1alpha1.Snapshot) string { return s.Name }) + // 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} - return snaps + 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) + + return snap } // resolveDRBDPort picks the DRBD listen port to preset on an RD / @@ -845,13 +876,27 @@ func (c *converter) snapshotCreatedAtFor(rdName, snapName string) int64 { return newest } -func (c *converter) snapshotNodesFor(rdName, snapName string) []string { +// 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 _, row := range c.dump.Resources { - if row.ResourceName == rdName && row.SnapshotName == snapName { - out = append(out, c.displayNode(row.NodeName)) + 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) diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index 22e4fb2b..5b159750 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -602,6 +602,109 @@ func TestOrphanReplicaSkipped(t *testing.T) { } } +// 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 diff --git a/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json index 1e74de49..445962fe 100644 --- a/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json @@ -1,21 +1,233 @@ -{"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"}} -]} +{ +"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": "" +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json index a30e34ae..a719819b 100644 --- a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json @@ -89,6 +89,15 @@ "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 +} } ] } diff --git a/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json index a200d14b..dc5ac70d 100644 --- a/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json @@ -1,9 +1,102 @@ -{"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"}} -]} +{ +"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" +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json index 1018b86f..08da48b1 100644 --- a/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json @@ -1,14 +1,134 @@ -{"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"}} -]} +{ +"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" +} +} +] +} diff --git a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json index 865480b1..3172af34 100644 --- a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json @@ -79,6 +79,16 @@ "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 +} } ] } diff --git a/pkg/linstormigrate/testdata/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index 66b552a6..2aa0831b 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -213,6 +213,18 @@ spec: 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: Resource metadata: name: pvc-vol1.node-a @@ -319,6 +331,16 @@ spec: 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: diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index 635200cf..7b47e014 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -1,9 +1,10 @@ -converted: 3 nodes, 3 storage pools, 3 resource groups, 4 resource definitions, 8 resources, 1 snapshots +converted: 3 nodes, 3 storage pools, 3 resource groups, 5 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-vol4.node-a: parent resource definition was not migrated — replica skipped warning: resource pvc-vol5.node-b: unhandled flags bits 2048 dropped (of 2048) From 888c9b8908d9068cb9d3c583889beeaea6ee1786 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 20 Jul 2026 16:21:51 +0200 Subject: [PATCH 17/18] fix(migrate): address review findings on #180 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human review (NOT LGTM) findings, in order of severity: [MAJOR] The StorDriver/StorPoolName fallback now covers LVM and LVM_THIN too, not just ZFS. A real LINSTOR database records the backing volume group (LVM) or / (LVM_THIN) in the same generic key while the kind-specific StorDriver/LvmVg and StorDriver/ThinPool stay blank, so an adopted LVM-backed pool hit the identical never-registers failure the ZFS fix addressed — and it would only surface at cutover, after the source controller is stopped. LVM_THIN splits the generic value; a value carrying only a VG still errors rather than guessing a thin-pool name. [MAJOR] A replica whose volumes span more than one storage pool is now skipped and reported instead of silently collapsing to volume 0's pool. LINSTOR allows a per-volume pool; blockstor's Resource holds one. The old behaviour sent blockstor looking for the other volumes' backing devices in the wrong pool — a fresh empty zvol beside the real data, silent for single-replica STORAGE-only volumes. That was a guess in a data-bearing field, against this tool's own never-guess contract. [MINOR] The adopted-snapshot gate is refused on a Snapshot already mid-orchestration (SuspendIO=true): short-circuiting there stranded drbdsetup suspend-io on every diskful peer with nothing left to resume it — frozen production I/O with no error. It now falls through to the normal orchestration, and the annotation godoc says so. [MINOR] linstor-migrate closes -out explicitly before reporting success, so an ENOSPC-style flush failure fails the run instead of leaving a truncated manifest behind with exit code 0. [MINOR] NODES.node_flags and VOLUMES.vlm_flags are no longer loaded and ignored: non-zero markers are reported (bit values deliberately not decoded — that would be a guess) so a node or volume the source cluster had flagged is never adopted silently. [MINOR] Replaced an internal cluster name that had leaked into two public code comments with a generic description. The snapshot-placement finding was already fixed in the preceding commit. Tests: LVM/LVM_THIN generic-key registration, divergent per-volume pools skipped, non-zero volume flags reported. Co-authored-by: Claude Signed-off-by: Andrei Kvapil --- .gitignore | 1 + api/v1alpha1/snapshot_types.go | 7 ++ cmd/linstor-migrate/main.go | 62 ++++++++++------ internal/controller/snapshot_controller.go | 17 ++++- pkg/linstormigrate/convert.go | 70 +++++++++++++++++++ pkg/linstormigrate/convert_test.go | 40 ++++++++++- ...sourceids.internal.linstor.linbit.com.json | 11 +++ ...gevolumes.internal.linstor.linbit.com.json | 18 +++++ ...estorpool.internal.linstor.linbit.com.json | 11 +++ ...ontainers.internal.linstor.linbit.com.json | 7 ++ ...finitions.internal.linstor.linbit.com.json | 12 ++++ ...resources.internal.linstor.linbit.com.json | 10 +++ ...finitions.internal.linstor.linbit.com.json | 29 ++++++-- ...finitions.internal.linstor.linbit.com.json | 20 ++++++ .../volumes.internal.linstor.linbit.com.json | 10 +++ .../testdata/golden/manifests.yaml | 26 +++++++ pkg/linstormigrate/testdata/golden/report.txt | 5 +- pkg/satellite/factory.go | 56 +++++++++++++-- pkg/satellite/factory_test.go | 57 ++++++++++++++- 19 files changed, 432 insertions(+), 37 deletions(-) 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/api/v1alpha1/snapshot_types.go b/api/v1alpha1/snapshot_types.go index d5f90632..187d0336 100644 --- a/api/v1alpha1/snapshot_types.go +++ b/api/v1alpha1/snapshot_types.go @@ -169,6 +169,13 @@ type SnapshotVolumeRef struct { // 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 diff --git a/cmd/linstor-migrate/main.go b/cmd/linstor-migrate/main.go index 7b2bac1f..fdd4f094 100644 --- a/cmd/linstor-migrate/main.go +++ b/cmd/linstor-migrate/main.go @@ -66,50 +66,62 @@ func run() int { return 2 } - opts, err := buildOptions(*portsPath) + err := convertToOutput(*inDir, *outPath, *portsPath) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 } - dump, err := linstormigrate.LoadDump(*inDir) + 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 { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + return err + } - return 1 + dump, err := linstormigrate.LoadDump(inDir) + if err != nil { + return fmt.Errorf("load dump: %w", err) } result, err := linstormigrate.ConvertWithOptions(dump, opts) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - - return 1 + return fmt.Errorf("convert: %w", err) } - out, closeOut, err := openOutput(*outPath) + out, closeOut, err := openOutput(outPath) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - - return 1 + return err } - defer closeOut() err = linstormigrate.WriteManifests(out, result) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + _ = closeOut() - return 1 + return fmt.Errorf("write manifests: %w", err) } - err = linstormigrate.WriteReport(os.Stderr, result) + // 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 { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + return err + } - return 1 + err = linstormigrate.WriteReport(os.Stderr, result) + if err != nil { + return fmt.Errorf("write report: %w", err) } - return 0 + return nil } // buildOptions loads the optional live DRBD port map from portsPath @@ -134,10 +146,12 @@ func buildOptions(portsPath string) (linstormigrate.Options, error) { } // openOutput resolves the -out flag: "-" streams to stdout (no-op -// closer), anything else creates the file and closes it on exit. -func openOutput(path string) (io.Writer, func(), error) { +// 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() {}, nil + return os.Stdout, func() error { return nil }, nil } file, err := os.Create(path) @@ -145,11 +159,13 @@ func openOutput(path string) (io.Writer, func(), error) { return nil, nil, fmt.Errorf("create %s: %w", path, err) } - closeFile := func() { + closeFile := func() error { closeErr := file.Close() if closeErr != nil { - fmt.Fprintf(os.Stderr, "error: close %s: %v\n", path, closeErr) + return fmt.Errorf("close %s: %w", path, closeErr) } + + return nil } return file, closeFile, nil diff --git a/internal/controller/snapshot_controller.go b/internal/controller/snapshot_controller.go index d0a55631..265db21b 100644 --- a/internal/controller/snapshot_controller.go +++ b/internal/controller/snapshot_controller.go @@ -110,8 +110,23 @@ func (r *SnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // 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 { - return r.backfillAdoptedSnapshot(ctx, logger, &snap) + 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 diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 3a67b47b..60ab122a 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -371,6 +371,16 @@ func (c *converter) convertNodes() []crdv1alpha1.Node { 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), @@ -687,6 +697,18 @@ func (c *converter) convertResources() []crdv1alpha1.Resource { 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)) } @@ -695,6 +717,52 @@ func (c *converter) convertResources() []crdv1alpha1.Resource { 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). @@ -704,6 +772,8 @@ func (c *converter) buildResource(row *ResourceRow, rdDsp, nodeDsp, replicaName 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{ diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index 5b159750..ead1e170 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -276,7 +276,7 @@ func TestZvolNameAdoptionInvariant(t *testing.T) { // 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 the live aenix-infra +// 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 @@ -602,6 +602,44 @@ func TestOrphanReplicaSkipped(t *testing.T) { } } +// 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") +} + +// 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 diff --git a/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json index 445962fe..87e82f58 100644 --- a/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerresourceids.internal.linstor.linbit.com.json @@ -228,6 +228,17 @@ "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 index a719819b..1832b6ea 100644 --- a/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/layerstoragevolumes.internal.linstor.linbit.com.json @@ -98,6 +98,24 @@ "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/nodestorpool.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json index 271ff33d..fb0da486 100644 --- a/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/nodestorpool.internal.linstor.linbit.com.json @@ -32,6 +32,17 @@ "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 index cbe9f044..6c10a06d 100644 --- a/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/propscontainers.internal.linstor.linbit.com.json @@ -181,6 +181,13 @@ "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 index dc5ac70d..700ea9ff 100644 --- a/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/resourcedefinitions.internal.linstor.linbit.com.json @@ -97,6 +97,18 @@ "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/resources.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json index 08da48b1..91a0ffc6 100644 --- a/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/resources.internal.linstor.linbit.com.json @@ -129,6 +129,16 @@ "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 index d2d51035..8253f9ca 100644 --- a/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/storpooldefinitions.internal.linstor.linbit.com.json @@ -1,4 +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"}} -]} +{ +"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 index 3172af34..3aee6ffd 100644 --- a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json @@ -89,6 +89,26 @@ "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 +} } ] } diff --git a/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json index 5217e874..32a27d2b 100644 --- a/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumes.internal.linstor.linbit.com.json @@ -99,6 +99,16 @@ "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 index 2aa0831b..944920cf 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -81,6 +81,17 @@ spec: 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: @@ -225,6 +236,21 @@ spec: 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 diff --git a/pkg/linstormigrate/testdata/golden/report.txt b/pkg/linstormigrate/testdata/golden/report.txt index 7b47e014..9461ec01 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -1,4 +1,4 @@ -converted: 3 nodes, 3 storage pools, 3 resource groups, 5 resource definitions, 9 resources, 1 snapshots +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 @@ -6,7 +6,10 @@ warning: resource definition pvc-vol4: marked DELETE in the source cluster — s 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-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/satellite/factory.go b/pkg/satellite/factory.go index 2ad6ce26..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" @@ -90,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 @@ -98,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 diff --git a/pkg/satellite/factory_test.go b/pkg/satellite/factory_test.go index 5d5d8bc4..58cf6133 100644 --- a/pkg/satellite/factory_test.go +++ b/pkg/satellite/factory_test.go @@ -128,9 +128,10 @@ func TestFactoryZFSFallsBackBetweenKeys(t *testing.T) { // TestFactoryZFSAdoptsStorPoolNameKey pins B1: a real LINSTOR ≥1.x // database stores the zpool name under the generic -// `StorDriver/StorPoolName` with BOTH ZPool keys blank (verified on -// the live aenix-infra cluster: sp l shows StorPoolName=data, -// ZPool/ZPoolThin empty). Before the fallback, NewProviderFromKind +// `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. @@ -171,6 +172,56 @@ func TestFactoryZFSAdoptsStorPoolNameKey(t *testing.T) { 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 From 30d30c443125120688f36f6b2bbd60a633bfc447 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 20 Jul 2026 16:35:06 +0200 Subject: [PATCH 18/18] docs+migrate: snapshot coverage/name checks from review follow-ups Closes the reviewer's remaining follow-ups on #180: - A snapshot that captured more than one volume is now reported: the ZFS provider addresses snapshots as /_00000@ (volume 0 only, zfs.go snapshotDataset), so the extra volume slots would otherwise imply restore coverage that does not exist on disk. - The runbook's pre-flight gains the mirror of the zvol-name check for snapshot datasets, so an adopted-snapshot name miss surfaces before cutover instead of at the first restore attempt. - The known-limitations table now names the volume-0-only snapshot scope, what an operator should do with an undecoded flag-bit warning, and the empirical node_type calibration (only SATELLITE confirmed against a live cluster). Co-authored-by: Claude Signed-off-by: Andrei Kvapil --- docs/linstor-migration.md | 10 +++++++ pkg/linstormigrate/convert.go | 11 ++++++++ pkg/linstormigrate/convert_test.go | 26 +++++++++++++++++++ ...finitions.internal.linstor.linbit.com.json | 10 +++++++ .../testdata/golden/manifests.yaml | 2 ++ pkg/linstormigrate/testdata/golden/report.txt | 1 + 6 files changed, 60 insertions(+) diff --git a/docs/linstor-migration.md b/docs/linstor-migration.md index 50d9f46c..4c5da1e1 100644 --- a/docs/linstor-migration.md +++ b/docs/linstor-migration.md @@ -58,6 +58,13 @@ Migrated: nodes, storage pools, resource groups, resource definitions (+ volume 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 @@ -111,3 +118,6 @@ Until CSI is repointed and confirmed, rollback is: scale blockstor to 0, scale ` | 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/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 60ab122a..5e734d71 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -881,6 +881,17 @@ func (c *converter) buildSnapshot(row *ResourceDefinitionRow, rdDsp, snapDsp, na 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 } diff --git a/pkg/linstormigrate/convert_test.go b/pkg/linstormigrate/convert_test.go index ead1e170..056ffe9a 100644 --- a/pkg/linstormigrate/convert_test.go +++ b/pkg/linstormigrate/convert_test.go @@ -627,6 +627,32 @@ func TestDivergentPerVolumePoolsSkipped(t *testing.T) { 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 diff --git a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json index 3aee6ffd..7ab61bd0 100644 --- a/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json +++ b/pkg/linstormigrate/testdata/dump/volumedefinitions.internal.linstor.linbit.com.json @@ -109,6 +109,16 @@ "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/golden/manifests.yaml b/pkg/linstormigrate/testdata/golden/manifests.yaml index 944920cf..e3601235 100644 --- a/pkg/linstormigrate/testdata/golden/manifests.yaml +++ b/pkg/linstormigrate/testdata/golden/manifests.yaml @@ -384,3 +384,5 @@ spec: 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 index 9461ec01..595a36bb 100644 --- a/pkg/linstormigrate/testdata/golden/report.txt +++ b/pkg/linstormigrate/testdata/golden/report.txt @@ -11,5 +11,6 @@ warning: resource pvc-vol3.node-a volume 0: non-zero vlm_flags 64 (not decoded; 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