diff --git a/cmd/satellite/coldstart_owned.go b/cmd/satellite/coldstart_owned.go index 9f64aa0a..b96f388b 100644 --- a/cmd/satellite/coldstart_owned.go +++ b/cmd/satellite/coldstart_owned.go @@ -11,6 +11,7 @@ import ( ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" k8sstore "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -42,7 +43,7 @@ func ownedOnNode(ctx context.Context, c ctrlclient.Client, nodeName string) (map owned := make(map[string]struct{}, len(replicas)) for i := range replicas { - owned[replicas[i].Name] = struct{}{} + owned[naming.Key(replicas[i].Name)] = struct{}{} } return owned, nil @@ -60,7 +61,7 @@ func selectOrphans(names []string, healthy, owned map[string]struct{}) ([]string continue } - if _, ok := owned[name]; !ok { + if _, ok := owned[naming.Key(name)]; !ok { foreign = append(foreign, name) continue diff --git a/cmd/satellite/coldstart_owned_test.go b/cmd/satellite/coldstart_owned_test.go index 854d7854..79e15730 100644 --- a/cmd/satellite/coldstart_owned_test.go +++ b/cmd/satellite/coldstart_owned_test.go @@ -156,3 +156,36 @@ func TestOwnedOnNodeNamesTheReplicasOfThisNode(t *testing.T) { t.Errorf("owned on srv1 = %v, want [pvc-a pvc-b]", got) } } + +// A definition registered as PVC-X runs in the kernel under its key, pvc-x, +// while the store shows its replica under the registered spelling. Cold start +// still knows that kernel resource for blockstor's, and takes it down when it +// is not healthy, instead of leaving it as another DRBD user's. +func TestColdStartOwnsAResourceRegisteredInMixedCase(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(&blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-x.srv1", + Annotations: map[string]string{"blockstor.io/linstor-resource-definition-name": "PVC-X"}, + }, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-x", NodeName: "srv1"}, + }). + Build() + + owned, err := ownedOnNode(t.Context(), c, "srv1") + if err != nil { + t.Fatalf("ownedOnNode: %v", err) + } + + orphans, foreign := selectOrphans([]string{"pvc-x"}, map[string]struct{}{}, owned) + if !slices.Equal(orphans, []string{"pvc-x"}) || len(foreign) != 0 { + t.Errorf("orphans %v, foreign %v; want pvc-x taken down as blockstor's", orphans, foreign) + } +} diff --git a/docs/linstor-migration.md b/docs/linstor-migration.md index 800625c8..7e23af5e 100644 --- a/docs/linstor-migration.md +++ b/docs/linstor-migration.md @@ -48,7 +48,7 @@ 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: +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`), where `` is the RD's `metadata.name` in the converted manifests: the LINSTOR name lowercased, or, for a name that is not a valid RFC 1123 label once lowercased, a hashed slug such as `35d21682-my-res` for `my_res`. Such a definition never matches the LINSTOR volume on disk, so rename it or the volume before cutover. `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: @@ -61,7 +61,7 @@ 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: + **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 diff --git a/go.mod b/go.mod index 56b6920f..d3adc9d4 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/prometheus/client_golang v1.23.2 golang.org/x/sys v0.39.0 golang.org/x/term v0.38.0 + golang.org/x/tools v0.39.0 k8s.io/api v0.35.0 k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apimachinery v0.35.0 @@ -91,7 +92,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.39.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect diff --git a/internal/cli/definition.go b/internal/cli/definition.go index a7d96832..5aac859f 100644 --- a/internal/cli/definition.go +++ b/internal/cli/definition.go @@ -29,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/cozystack/blockstor/internal/cli/command" @@ -365,11 +366,11 @@ func candidatePools(ctx context.Context, run *runContext, group *apiv1.ResourceG continue case !matchesAny(wanted, pool.StoragePoolName): continue - case seen[pool.NodeName]: + case seen[naming.Key(pool.NodeName)]: continue } - seen[pool.NodeName] = true + seen[naming.Key(pool.NodeName)] = true pools = append(pools, *pool) } @@ -388,7 +389,7 @@ func matchesAny(filter []string, name string) bool { return true } - return slices.Contains(filter, name) + return slices.ContainsFunc(filter, func(f string) bool { return naming.Same(f, name) }) } // maxVolumeSizeKib is the smallest free capacity among the pools a diff --git a/internal/cli/flags.go b/internal/cli/flags.go index c0ff0c2c..24ff3c94 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -24,6 +24,7 @@ import ( "strings" "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/internal/cli/command" ) @@ -367,15 +368,15 @@ func splitList(value string) []string { return out } -// matches reports whether a value passes a filter list (empty filter -// matches everything). +// matches reports whether a name passes a filter list, comparing as the +// store addresses objects (naming.Same); an empty filter matches everything. func matches(filter []string, value string) bool { if len(filter) == 0 { return true } for _, want := range filter { - if strings.EqualFold(want, value) { + if naming.Same(want, value) { return true } } diff --git a/internal/cli/guards_test.go b/internal/cli/guards_test.go index f33fe82a..459f8916 100644 --- a/internal/cli/guards_test.go +++ b/internal/cli/guards_test.go @@ -92,6 +92,38 @@ func TestMigrateDiskRefusesSelfMigration(t *testing.T) { } } +// The two ends are compared as the store addresses nodes: Node-1 and node-1 +// are one node, so `r td node-1 pvc-x --migrate-from Node-1` is a migration +// onto its own source and is refused with nothing written, neither a mark on +// the source nor a second replica. +func TestMigrateDiskRefusesSelfMigrationInAnotherSpelling(t *testing.T) { + t.Parallel() + + app, _, errBuf := newApp(t, func(ctx context.Context, backend store.Store) { + _ = backend.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1"}) + _ = backend.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}) + _ = backend.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-x", NodeName: "Node-1"}) + }) + + argv := []string{"r", "td", "node-1", "pvc-x", "--migrate-from", "Node-1"} + if got := app.Run(t.Context(), argv); got == 0 { + t.Fatalf("self-migration in another spelling was accepted (stderr: %s)", errBuf.String()) + } + + replicas, err := appStore(t, app).Resources().ListByDefinition(t.Context(), "pvc-x") + if err != nil { + t.Fatalf("list replicas: %v", err) + } + + if len(replicas) != 1 { + t.Fatalf("the refused migration left %d replicas, want the source alone: %+v", len(replicas), replicas) + } + + if replicas[0].Props["BlockstorMigratingFrom"] != "" { + t.Errorf("the refused migration still stamped the source: %v", replicas[0].Props) + } +} + // A restore onto a node that does not hold the snapshot creates a replica // with nothing behind it and reports success. func TestRestoreRefusesNodesWithoutTheSnapshot(t *testing.T) { diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 65fe1fe1..8800d362 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -27,6 +27,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" @@ -311,14 +312,13 @@ func volumeDefinitionList(ctx context.Context, run *runContext) error { // colour classifier went to the trouble of stripping it. // // The replicas are read whole and filtered in process, `-n` and `-r` -// included. The filter compares the way LINSTOR does, case-insensitively, -// and a replica's spec keeps whatever case its writer used: an adoption -// run or linstor-csi can store `NODE-1` for a node the operator types as -// `node-1`. The node- and definition-scoped reads compare the stored -// spelling verbatim (see store.FoldName), so answering a narrowed listing -// with them returned fewer rows than this filter finds, with exit status -// zero. Narrowing the read without that loss needs the folded selectable -// field FoldName describes; until it exists, the listing stays whole. +// included. The filter compares names as the store addresses them +// (naming.Same), and a replica's spec keeps whatever case its writer used: +// an adoption run or linstor-csi can store `NODE-1` for a node the operator +// types as `node-1`. The node- and definition-scoped reads compare the stored +// spelling verbatim, as a field selector does (pkg/store/k8s/spellings.go), +// so answering a narrowed listing with them returned fewer rows than this +// filter finds, with exit status zero; the listing stays whole. func resourceList(ctx context.Context, run *runContext) error { resources, err := fetchResources(ctx, run.Store) if err != nil { @@ -512,7 +512,7 @@ func volumeSizesInOneRequest( sizes := make(map[string]map[int32]int64, len(names)) for _, name := range names { - vds, ok := all[store.FoldName(name)] + vds, ok := all[naming.Key(name)] if !ok { continue } diff --git a/internal/cli/layer_list_case_test.go b/internal/cli/layer_list_case_test.go new file mode 100644 index 00000000..938f1ed6 --- /dev/null +++ b/internal/cli/layer_list_case_test.go @@ -0,0 +1,40 @@ +// 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 cli + +import ( + "slices" + "testing" +) + +// The CLI writes the CRDs directly, so it stores the stack in the same +// canonical case as the REST doors. +func TestParseLayerListStoresCanonicalCase(t *testing.T) { + t.Parallel() + + got, err := parseLayerList("drbd,Luks,storage") + if err != nil { + t.Fatalf("parse: %v", err) + } + + want := []string{"DRBD", "LUKS", "STORAGE"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/internal/cli/name_filter_test.go b/internal/cli/name_filter_test.go new file mode 100644 index 00000000..e85cf112 --- /dev/null +++ b/internal/cli/name_filter_test.go @@ -0,0 +1,55 @@ +// 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 cli + +import "testing" + +// A list filter names objects the way the store addresses them: PVC-X finds +// pvc-x, and my_res does not find My_Res, a definition of its own. +func TestListFiltersMatchByKey(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + filter, value string + want bool + }{ + {"PVC-X", "pvc-x", true}, + {"Node-1", "node-1", true}, + {"my_res", "My_Res", false}, + {"My_Res", "My_Res", true}, + {"pvc-y", "pvc-x", false}, + } { + if got := matches([]string{tc.filter}, tc.value); got != tc.want { + t.Errorf("matches(%q, %q) = %v, want %v", tc.filter, tc.value, got, tc.want) + } + } +} + +// A group's pool filter names pools the way the store addresses them. +func TestPoolFilterMatchesByKey(t *testing.T) { + t.Parallel() + + if !matchesAny([]string{"ThinPool"}, "thinpool") { + t.Error("filter ThinPool does not admit pool thinpool") + } + + if matchesAny([]string{"thin_pool"}, "Thin_Pool") { + t.Error("filter thin_pool admits Thin_Pool, another pool") + } +} diff --git a/internal/cli/node_spelling_pool_test.go b/internal/cli/node_spelling_pool_test.go new file mode 100644 index 00000000..3b5835fa --- /dev/null +++ b/internal/cli/node_spelling_pool_test.go @@ -0,0 +1,55 @@ +// 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 cli + +import ( + "testing" + + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store" +) + +// The wire spells a replica's node as it was registered, Node-1, while the +// operator names the node node-1. The pool a restore or a create infers is +// the one of that node's own replica, or of a sibling, never the other way. +func TestPoolInferenceReadsTheNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + run := &runContext{Store: st, Flags: &flagSet{Values: map[string]string{}}} + + for _, node := range []string{"Node-1", "Node-2"} { + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-mix", + NodeName: node, + Props: map[string]string{storPoolNameProp: "pool-" + node}, + }); err != nil { + t.Fatalf("seed replica on %s: %v", node, err) + } + } + + if pool, err := sourcePoolOn(ctx, run, "pvc-mix", "node-2"); err != nil || pool != "pool-Node-2" { + t.Errorf("sourcePoolOn(node-2) = %q, %v; want the pool of the replica on Node-2", pool, err) + } + + if pool, err := resolveStorPool(ctx, run, "pvc-mix", "node-1"); err != nil || pool != "pool-Node-2" { + t.Errorf("resolveStorPool(node-1) = %q, %v; want the sibling's pool, not node-1's own", pool, err) + } +} diff --git a/internal/cli/physical.go b/internal/cli/physical.go index d61cded7..3f6e6147 100644 --- a/internal/cli/physical.go +++ b/internal/cli/physical.go @@ -25,6 +25,7 @@ import ( "strings" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/cozystack/blockstor/internal/cli/command" @@ -338,7 +339,7 @@ func sameAttach(existing, wanted *apiv1.PhysicalDeviceAttachTo) bool { return false } - return existing.StoragePoolName == wanted.StoragePoolName && + return naming.Same(existing.StoragePoolName, wanted.StoragePoolName) && existing.ProviderKind == wanted.ProviderKind && existing.VGName == wanted.VGName && existing.ThinPoolName == wanted.ThinPoolName && diff --git a/internal/cli/place.go b/internal/cli/place.go index ee57c72c..98d3974f 100644 --- a/internal/cli/place.go +++ b/internal/cli/place.go @@ -26,6 +26,7 @@ import ( "strings" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" @@ -352,7 +353,7 @@ func resourceGroupAdjust(ctx context.Context, run *runContext) error { continue } - if wanted != "" && !strings.EqualFold(wanted, defs[i].ResourceGroupName) { + if wanted != "" && !naming.Same(wanted, defs[i].ResourceGroupName) { continue } diff --git a/internal/cli/pool.go b/internal/cli/pool.go index 6a4d1e11..0fb20305 100644 --- a/internal/cli/pool.go +++ b/internal/cli/pool.go @@ -27,6 +27,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/internal/cli/command" "github.com/cozystack/blockstor/internal/cli/view" @@ -257,7 +258,7 @@ func volumeGroupList(ctx context.Context, run *runContext) error { rows := make([]volumeGroupRow, 0) for i := range groups { - if wanted != "" && !strings.EqualFold(wanted, groups[i].Name) { + if wanted != "" && !naming.Same(wanted, groups[i].Name) { continue } diff --git a/internal/cli/resource.go b/internal/cli/resource.go index 0478bd24..ec929f43 100644 --- a/internal/cli/resource.go +++ b/internal/cli/resource.go @@ -25,6 +25,7 @@ import ( "slices" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/internal/cli/command" ) @@ -238,7 +239,7 @@ func migrateDisk(ctx context.Context, run *runContext, dst, rdName string) error // // There is no --force path for this: no argument makes deleting the // copy you are migrating the intended outcome. - if src == dst { + if naming.Same(src, dst) { return fmt.Errorf("migrate-disk: source and destination are both %s: %w", dst, errMigrateToSelf) } diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 64593c0b..9b4cc55a 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -32,6 +32,7 @@ import ( "github.com/cozystack/blockstor/pkg/validate" "github.com/cozystack/blockstor/internal/cli/command" + "github.com/cozystack/blockstor/pkg/naming" ) // errRollbackUnsupported explains a deliberate omission. @@ -588,7 +589,7 @@ func replayOwnRestore(ctx context.Context, run *runContext, args restoreArgs) (b return false, true, fmt.Errorf("get resource definition %s: %w", args.toResource, err) } - if !strings.EqualFold(existing.Props[store.RestoreFromSnapshotProp], args.fromResource+":"+args.fromSnapshot) { + if !store.SameRestoreMarker(existing.Props[store.RestoreFromSnapshotProp], args.fromResource+":"+args.fromSnapshot) { return true, false, nil } @@ -906,7 +907,7 @@ func placeCommands(rdName string, planned []apiv1.Resource, missing []string) st cmd.WriteString("`blockstor resource create " + node + " " + rdName) for i := range planned { - if planned[i].NodeName == node && planned[i].Props[storPoolNameProp] != "" { + if naming.Same(planned[i].NodeName, node) && planned[i].Props[storPoolNameProp] != "" { cmd.WriteString(" --storage-pool " + planned[i].Props[storPoolNameProp]) break @@ -1317,7 +1318,7 @@ func sourcePoolOn(ctx context.Context, run *runContext, srcRD, node string) (str continue } - if replicas[i].NodeName == node { + if naming.Same(replicas[i].NodeName, node) { return pool, nil } diff --git a/internal/cli/write_more.go b/internal/cli/write_more.go index 34f719a3..daa984ec 100644 --- a/internal/cli/write_more.go +++ b/internal/cli/write_more.go @@ -33,6 +33,7 @@ import ( "github.com/cozystack/blockstor/pkg/validate" "github.com/cozystack/blockstor/internal/cli/command" + "github.com/cozystack/blockstor/pkg/naming" ) // kibPerMib is the binary step between adjacent size suffixes. The @@ -65,13 +66,11 @@ func parseInt32(text, what string) (int32, error) { // CLI writes the CRDs directly, and the field carries no enum or CEL rule, so // without this check there is no backstop anywhere on this path. // -// The value is stored as the operator typed it, matching what REST persists; -// the satellite compares case-insensitively, so only the spelling is at issue -// here, not the case. +// The value is stored in canonical case, as the REST doors store it: some +// readers compare the stored stack exactly, so `drbd,storage` kept as typed +// read as a stack without DRBD to them. func parseLayerList(raw string) ([]string, error) { - layers := splitList(raw) - - err := validate.LayerStack(layers) + layers, err := validate.CanonicalLayerStack(splitList(raw)) if err != nil { //nolint:wrapcheck // a semantic refusal; see checkVolumeSize return nil, err @@ -766,7 +765,7 @@ func resolveStorPool(ctx context.Context, run *runContext, rdName, node string) siblings, err := run.Store.Resources().ListByDefinition(ctx, rdName) if err == nil { for i := range siblings { - if siblings[i].NodeName == node || + if naming.Same(siblings[i].NodeName, node) || slices.Contains(siblings[i].Flags, apiv1.ResourceFlagDiskless) { continue } diff --git a/internal/controller/auto_diskful_controller.go b/internal/controller/auto_diskful_controller.go index ae33113f..9ad07534 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.go @@ -31,6 +31,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -326,7 +327,7 @@ func (r *AutoDiskfulReconciler) firstAvailablePool(ctx context.Context, nodeName } for i := range pools { - if pools[i].NodeName != nodeName { + if !naming.Same(pools[i].NodeName, nodeName) { continue } @@ -375,7 +376,7 @@ func (r *AutoDiskfulReconciler) stampDeadlineViaCRD(ctx context.Context, rdName } var crd blockstoriov1alpha1.ResourceDefinition - if err := r.Get(ctx, client.ObjectKey{Name: rdName}, &crd); err != nil { + if err := r.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &crd); err != nil { return false } @@ -414,7 +415,7 @@ func (r *AutoDiskfulReconciler) stripDeadlineViaCRD(ctx context.Context, rdName } var crd blockstoriov1alpha1.ResourceDefinition - if err := r.Get(ctx, client.ObjectKey{Name: rdName}, &crd); err != nil { + if err := r.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &crd); err != nil { return false } @@ -463,7 +464,7 @@ func splitDiskfulAndCandidates(replicas []apiv1.Resource, disabled map[string]st ) for i := range replicas { - if _, off := disabled[replicas[i].NodeName]; off { + if _, off := disabled[naming.Key(replicas[i].NodeName)]; off { // Replica on an EVICTED/LOST node — treat as absent. continue } @@ -490,7 +491,7 @@ func splitDiskfulAndCandidates(replicas []apiv1.Resource, disabled map[string]st return diskful, candidates } -// disabledNodeSet returns the EVICTED/LOST node names as a lookup set, +// disabledNodeSet returns the EVICTED/LOST nodes as a set keyed by CR name, // reusing the shared isDisabledNode predicate (defined alongside the // RD-controller tiebreaker path) so auto-diskful gates on exactly the // same disabled-node definition the placer uses. Eviction is the @@ -506,7 +507,7 @@ func (r *AutoDiskfulReconciler) disabledNodeSet(ctx context.Context) (map[string for i := range nodes { if isDisabledNode(&nodes[i]) { - out[nodes[i].Name] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} } } diff --git a/internal/controller/autosnapshot_controller.go b/internal/controller/autosnapshot_controller.go index f566bdb2..cbe6e008 100644 --- a/internal/controller/autosnapshot_controller.go +++ b/internal/controller/autosnapshot_controller.go @@ -23,7 +23,6 @@ import ( "fmt" "sort" "strconv" - "strings" "time" "github.com/cockroachdb/errors" @@ -513,7 +512,7 @@ func (r *AutoSnapshotRunnable) restoreReadsFrom(ctx context.Context, rdName, sna } for i := range rds.Items { - if strings.EqualFold(rds.Items[i].Spec.Props[store.RestoreFromSnapshotProp], rdName+":"+snapName) { + if store.SameRestoreMarker(rds.Items[i].Spec.Props[store.RestoreFromSnapshotProp], rdName+":"+snapName) { return true, nil } } diff --git a/internal/controller/ensure_tiebreaker_evict_spelling_test.go b/internal/controller/ensure_tiebreaker_evict_spelling_test.go new file mode 100644 index 00000000..d42d09e7 --- /dev/null +++ b/internal/controller/ensure_tiebreaker_evict_spelling_test.go @@ -0,0 +1,100 @@ +// 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_test + +import ( + "context" + "slices" + "testing" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + controllerpkg "github.com/cozystack/blockstor/internal/controller" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// A witness on an EVICTED node is moved to a healthy spare when the node +// is registered as Node-1: the replica stores the node's CR name, node-1, +// while the node list shows Node-1, and the two are one node. +func TestAWitnessOnAnEvictedMixedCaseNodeIsReplaced(t *testing.T) { + t.Parallel() + + scheme := newScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + st := k8s.New(cli) + ctx := context.Background() + + for _, n := range []apiv1.Node{ + {Name: "Node-1", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}, + {Name: "Node-2", Type: apiv1.NodeTypeSatellite}, + {Name: "Node-3", Type: apiv1.NodeTypeSatellite}, + {Name: "Node-4", Type: apiv1.NodeTypeSatellite}, + } { + if err := st.Nodes().Create(ctx, &n); err != nil { + t.Fatalf("seed node %s: %v", n.Name, err) + } + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-ev"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + for _, n := range []string{"Node-2", "Node-3"} { + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-ev", NodeName: n}); err != nil { + t.Fatalf("seed diskful on %s: %v", n, err) + } + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-ev", NodeName: "Node-1", + Flags: []string{apiv1.ResourceFlagDiskless, apiv1.ResourceFlagTieBreaker}, + }); err != nil { + t.Fatalf("seed witness: %v", err) + } + + var rd blockstoriov1alpha1.ResourceDefinition + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc-ev"}, &rd); err != nil { + t.Fatalf("get definition: %v", err) + } + + rec := &controllerpkg.ResourceDefinitionReconciler{Client: cli, Scheme: scheme, Store: st} + if err := rec.EnsureTiebreaker(ctx, &rd); err != nil { + t.Fatalf("EnsureTiebreaker: %v", err) + } + + all, err := st.Resources().ListByDefinition(ctx, "pvc-ev") + if err != nil { + t.Fatalf("list: %v", err) + } + + var witnesses []string + + for i := range all { + if slices.Contains(all[i].Flags, apiv1.ResourceFlagTieBreaker) { + witnesses = append(witnesses, all[i].NodeName) + } + } + + if !slices.Equal(witnesses, []string{"Node-4"}) { + t.Errorf("witnesses on %v, want only the spare Node-4; replicas %v", witnesses, all) + } +} diff --git a/internal/controller/export_test.go b/internal/controller/export_test.go index eaa8e9c9..a5bd426b 100644 --- a/internal/controller/export_test.go +++ b/internal/controller/export_test.go @@ -243,3 +243,8 @@ const KeepTiebreakerUntilAnnotation = apiv1.KeepTiebreakerUntilAnnotation // RDReconcileRequeue exposes the RD reconciler's periodic requeue. const RDReconcileRequeue = rdReconcileRequeue + +// CurrentDiskfulTarget exposes the evacuation drain target for tests. +func (r *NodeReconciler) CurrentDiskfulTarget(ctx context.Context, rdName, evictedNode string) (int, error) { + return r.currentDiskfulTarget(ctx, rdName, evictedNode) +} diff --git a/internal/controller/mixed_case_replicas_test.go b/internal/controller/mixed_case_replicas_test.go new file mode 100644 index 00000000..eaf580cd --- /dev/null +++ b/internal/controller/mixed_case_replicas_test.go @@ -0,0 +1,74 @@ +// 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" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" +) + +func mixedCaseReplica(rd, node string) *blockstoriov1alpha1.Resource { + return &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(rd) + "." + node}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: rd, NodeName: node}, + } +} + +// A definition registered as Pvc-Mix is stored under pvc-mix. A replica +// written or adopted before the store kept CR names may carry the display +// spelling Pvc-Mix in spec, next to +// one the store wrote under pvc-mix. The controller counts them both, and +// nothing else. +func TestControllerSeesReplicasOfAMixedCaseDefinition(t *testing.T) { + t.Parallel() + + cli := fake.NewClientBuilder().WithScheme(bug048Scheme(t)).WithObjects( + mixedCaseReplica("Pvc-Mix", "n1"), mixedCaseReplica("pvc-mix", "n2"), mixedCaseReplica("pvc-other", "n1"), + ).Build() + + r := &ResourceDefinitionReconciler{Client: cli, APIReader: cli} + + got, err := r.listReplicasDirect(context.Background(), naming.Key("Pvc-Mix")) + if err != nil { + t.Fatalf("list: %v", err) + } + + if len(got) != 2 { + t.Errorf("replicas of Pvc-Mix = %v, want the two on n1 and n2", got) + } +} + +// A replica event enqueues the definition under the name its CR has, also +// for a replica that spells it otherwise. +func TestReplicaEventEnqueuesTheFoldedDefinition(t *testing.T) { + t.Parallel() + + r := &ResourceDefinitionReconciler{} + + reqs := r.enqueueRDForResource(context.Background(), mixedCaseReplica("Pvc-Mix", "n1")) + if len(reqs) != 1 || reqs[0].Name != naming.Key("Pvc-Mix") { + t.Errorf("enqueued %v, want %s", reqs, naming.Key("Pvc-Mix")) + } +} diff --git a/internal/controller/name_key_test.go b/internal/controller/name_key_test.go new file mode 100644 index 00000000..601a8ed5 --- /dev/null +++ b/internal/controller/name_key_test.go @@ -0,0 +1,176 @@ +// 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_test + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + controllerpkg "github.com/cozystack/blockstor/internal/controller" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store" +) + +// A node registered as Node-1 is reconciled under its CR name, node-1, while +// the store shows its replicas on Node-1. The drain still counts the lone +// replica it is evacuating: it gap-fills a replacement and keeps the source, +// instead of reading the source as a replica on some other draining node and +// deriving a drain target of zero. +func TestEvacuatingANodeRegisteredInMixedCaseKeepsItsLoneSource(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := newScheme(t) + st := store.NewInMemory() + + for _, name := range []string{"Node-1", "n2", "n3"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: name, Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + StoragePoolName: "pool", NodeName: name, ProviderKind: apiv1.StoragePoolKindLVMThin, + }); err != nil { + t.Fatalf("seed pool: %v", err) + } + } + + if err := st.ResourceGroups().Create(ctx, &apiv1.ResourceGroup{ + Name: "DfltRscGrp", SelectFilter: apiv1.AutoSelectFilter{StoragePool: "pool"}, + }); err != nil { + t.Fatalf("seed RG: %v", err) + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-solo", ResourceGroupName: "DfltRscGrp"}); err != nil { + t.Fatalf("seed RD: %v", err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-solo", NodeName: "Node-1"}); err != nil { + t.Fatalf("seed resource: %v", err) + } + + if err := st.Nodes().Update(ctx, &apiv1.Node{Name: "Node-1", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}); err != nil { + t.Fatalf("flag Node-1 evicted: %v", err) + } + + srcCRD := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-solo.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-solo", NodeName: "node-1"}, + } + nodeCRD := &blockstoriov1alpha1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1", Annotations: map[string]string{"blockstor.io/linstor-name": "Node-1"}}, + Spec: blockstoriov1alpha1.NodeSpec{Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}, + } + + cli := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&blockstoriov1alpha1.Resource{}). + WithObjects(nodeCRD, srcCRD). + Build() + + rec := &controllerpkg.NodeReconciler{Client: cli, Scheme: scheme, Store: st} + + if _, err := rec.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-1"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + replicas, err := st.Resources().ListByDefinition(ctx, "pvc-solo") + if err != nil { + t.Fatalf("list replicas: %v", err) + } + + if !hasDiskfulOn(replicas, "n2") && !hasDiskfulOn(replicas, "n3") { + t.Errorf("no replacement gap-filled for the lone replica on Node-1; replicas=%+v", replicas) + } + + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc-solo.node-1"}, &blockstoriov1alpha1.Resource{}); err != nil { + t.Errorf("the lone source was pruned before a replacement exists: %v", err) + } +} + +// The drain target leaves out replicas on other draining nodes, whichever +// spelling a replica row keeps for its node: a row stored as n2 is on the node +// registered as N2, which is LOST. +func TestTheDrainTargetSkipsADrainingNodeInAnySpelling(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewInMemory() + + for _, n := range []apiv1.Node{ + {Name: "Node-1", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}, + {Name: "N2", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagLost}}, + {Name: "n3", Type: apiv1.NodeTypeSatellite}, + {Name: "n4", Type: apiv1.NodeTypeSatellite}, + {Name: "n5", Type: apiv1.NodeTypeSatellite}, + } { + if err := st.Nodes().Create(ctx, &n); err != nil { + t.Fatalf("seed node: %v", err) + } + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-t"}); err != nil { + t.Fatalf("seed RD: %v", err) + } + + for _, node := range []string{"Node-1", "n2", "n3"} { + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-t", NodeName: node}); err != nil { + t.Fatalf("seed replica on %s: %v", node, err) + } + } + + rec := &controllerpkg.NodeReconciler{Store: st} + + got, err := rec.CurrentDiskfulTarget(ctx, "pvc-t", "node-1") + if err != nil { + t.Fatalf("CurrentDiskfulTarget: %v", err) + } + + if got != 2 { + t.Errorf("drain target %d, want 2: the evacuated replica and the one on n3, not the one on LOST N2", got) + } +} + +// A replica row written before the store kept CR names in spec may name its +// definition Pvc-Mix; the reconciler still finds the definition, stored as +// pvc-mix, instead of reading it as missing. +func TestTheResourceReconcilerFindsADefinitionInAnySpelling(t *testing.T) { + t.Parallel() + + scheme := newScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(&blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}}). + Build() + + rec := &controllerpkg.ResourceReconciler{Client: cli, Scheme: scheme} + + rd, err := rec.LookupRD(context.Background(), "Pvc-Mix") + if err != nil { + t.Fatalf("LookupRD: %v", err) + } + + if rd == nil || rd.Name != "pvc-mix" { + t.Errorf("LookupRD(Pvc-Mix) = %v, want the definition pvc-mix", rd) + } +} diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index 0058191b..d8b2d89a 100644 --- a/internal/controller/node_controller.go +++ b/internal/controller/node_controller.go @@ -31,6 +31,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" ) @@ -120,7 +121,7 @@ func (r *NodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } for i := range resList { - if resList[i].NodeName != node.Name { + if !naming.Same(resList[i].NodeName, node.Name) { continue } @@ -274,7 +275,7 @@ func (r *NodeReconciler) currentDiskfulTarget(ctx context.Context, rdName, evict healthyNodes := 0 for i := range nodes { - if _, off := drained[nodes[i].Name]; off { + if _, off := drained[naming.Key(nodes[i].Name)]; off { continue } @@ -293,8 +294,8 @@ func (r *NodeReconciler) currentDiskfulTarget(ctx context.Context, rdName, evict // Other draining nodes don't count; the evacuated node does (it // is the replica we are replacing). - if node != evictedNode { - if _, off := drained[node]; off { + if !naming.Same(node, evictedNode) { + if _, off := drained[naming.Key(node)]; off { continue } } @@ -322,7 +323,7 @@ func (r *NodeReconciler) currentDiskfulTarget(ctx context.Context, rdName, evict func (r *NodeReconciler) pruneSource(ctx context.Context, rdName, node string) error { resCRD := &blockstoriov1alpha1.Resource{} - err := r.Get(ctx, client.ObjectKey{Name: resourceCRDName(rdName, node)}, resCRD) + err := getReplica(ctx, r.Client, rdName, node, resCRD) if err != nil { return client.IgnoreNotFound(err) } @@ -376,11 +377,11 @@ func (r *NodeReconciler) evacuationReplacementReady(ctx context.Context, filter // Skip the source on the evacuated node and any replica still // pinned to a draining (EVICTED / LOST) peer — neither counts // toward the post-drain redundancy we must reach first. - if node == evictedNode { + if naming.Same(node, evictedNode) { continue } - if _, off := drained[node]; off { + if _, off := drained[naming.Key(node)]; off { continue } @@ -412,7 +413,7 @@ func (r *NodeReconciler) evacuationReplacementReady(ctx context.Context, filter func (r *NodeReconciler) replicaUpToDate(ctx context.Context, rdName, node string) (bool, error) { resCRD := &blockstoriov1alpha1.Resource{} - err := r.Get(ctx, client.ObjectKey{Name: resourceCRDName(rdName, node)}, resCRD) + err := getReplica(ctx, r.Client, rdName, node, resCRD) if err != nil { if apierrors.IsNotFound(err) { return false, nil @@ -439,19 +440,13 @@ func (r *NodeReconciler) drainingNodes(ctx context.Context) (map[string]struct{} for i := range nodes { if slices.Contains(nodes[i].Flags, apiv1.NodeFlagEvicted) || slices.Contains(nodes[i].Flags, apiv1.NodeFlagLost) { - out[nodes[i].Name] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} } } return out, nil } -// resourceCRDName mirrors the encoding used by the k8s store — -// `.` is the documented composite key. -func resourceCRDName(rd, node string) string { - return rd + "." + node -} - // annotateMigrationBlocked stamps the parent RD with the // `blockstor.io/migration-blocked` annotation and the given reason so // an operator (`kubectl get rd -o ...`) can surface every RD whose diff --git a/internal/controller/node_heartbeat_controller.go b/internal/controller/node_heartbeat_controller.go index 90873699..ecb48eaa 100644 --- a/internal/controller/node_heartbeat_controller.go +++ b/internal/controller/node_heartbeat_controller.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" ) // Node-monitor knobs — mirror the kube-controller-manager defaults @@ -206,7 +207,7 @@ func (r *NodeHeartbeatReconciler) applyState( Kind: nodeKind, APIVersion: blockstoriov1alpha1.GroupVersion.String(), }, - ObjectMeta: metav1.ObjectMeta{Name: node.Name}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(node.Name)}, Status: blockstoriov1alpha1.NodeStatus{ ConnectionStatus: desiredConn, Conditions: []metav1.Condition{ diff --git a/internal/controller/node_set_identity_test.go b/internal/controller/node_set_identity_test.go new file mode 100644 index 00000000..3c755d40 --- /dev/null +++ b/internal/controller/node_set_identity_test.go @@ -0,0 +1,181 @@ +// 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" + "sync/atomic" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// The direct node probe finds a node by the CR name its wire name maps to: +// My_Node by its own spelling and by its CR name, never by my_node, which +// hashes to another CR. +func TestNodeProbeKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { + t.Parallel() + + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Node")}} + k8s.SetOriginalName(&node.ObjectMeta, "My_Node") + + r := &ResourceDefinitionReconciler{APIReader: fake.NewClientBuilder().WithScheme(bug048Scheme(t)).WithObjects(node).Build()} + + for name, want := range map[string]bool{"My_Node": true, naming.Key("My_Node"): true, "my_node": false} { + probe, err := r.probeNodeDirect(context.Background(), name) + if err != nil || probe.found != want { + t.Errorf("probe %q = %+v, %v; want found=%v", name, probe, err, want) + } + } +} + +// A replica on a node the store lists is live without a direct probe: the +// replica names the node by its CR name, the list by the registered +// spelling, and the two are one node. +func TestAReplicaOnAListedMixedCaseNodeIsNotProbed(t *testing.T) { + t.Parallel() + + cli := fake.NewClientBuilder().WithScheme(bug048Scheme(t)).Build() + st := k8s.New(cli) + + if err := st.Nodes().Create(context.Background(), &apiv1.Node{Name: "Node-2", Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("seed node: %v", err) + } + + var probes atomic.Int32 + + r := &ResourceDefinitionReconciler{Store: st, APIReader: interceptor.NewClient(cli, interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + probes.Add(1) + + return c.List(ctx, list, opts...) + }, + })} + + _, known, err := r.nodeSets(context.Background()) + if err != nil { + t.Fatalf("nodeSets: %v", err) + } + + replicas := []apiv1.Resource{{Name: "pvc-x", NodeName: "node-2"}, {Name: "pvc-y", NodeName: "Node-2"}} + + live, ghosts, err := r.confirmGhostReplicas(context.Background(), replicas, known) + if err != nil || len(live) != 2 || len(ghosts) != 0 { + t.Fatalf("live %v, ghosts %v, %v; want both replicas live", live, ghosts, err) + } + + if n := probes.Load(); n != 0 { + t.Errorf("a replica on a listed node took %d direct probes", n) + } +} + +// The witness selector never picks a node that hosts a diskful replica of +// the definition, whichever spelling the replica listing shows for it. +func TestTheWitnessSelectorExcludesADiskfulMixedCaseNode(t *testing.T) { + t.Parallel() + + cli := fake.NewClientBuilder().WithScheme(bug048Scheme(t)).Build() + st := k8s.New(cli) + ctx := context.Background() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-2", Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-x", NodeName: "Node-2"}); err != nil { + t.Fatalf("seed replica: %v", err) + } + + r := &ResourceDefinitionReconciler{Store: st} + + got, err := r.pickTiebreakerNodeForRD(ctx, "pvc-x", nil) + if err != nil || got != "" { + t.Errorf("witness node = %q, %v; want none, Node-2 hosts a diskful replica", got, err) + } +} + +// The caller's hosting set excludes its nodes whichever spelling it uses. +func TestTheWitnessSelectorExcludesAHostingNodeInAnySpelling(t *testing.T) { + t.Parallel() + + st := k8s.New(fake.NewClientBuilder().WithScheme(bug048Scheme(t)).Build()) + + if err := st.Nodes().Create(context.Background(), &apiv1.Node{Name: "Node-2", Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("seed node: %v", err) + } + + r := &ResourceDefinitionReconciler{Store: st} + + got, err := r.pickTiebreakerNode(context.Background(), map[string]bool{"NODE-2": true}) + if err != nil || got != "" { + t.Errorf("witness node = %q, %v; want none, NODE-2 already hosts a replica", got, err) + } +} + +// Auto-diskful drops a replica on an EVICTED node whichever spelling the +// replica shows for it: an adopted row stores the node's CR name, node-1, +// with no display annotation, while the node list shows Node-1. +func TestAutoDiskfulDropsAReplicaOnAnEvictedMixedCaseNode(t *testing.T) { + t.Parallel() + + cli := fake.NewClientBuilder().WithScheme(bug048Scheme(t)).Build() + st := k8s.New(cli) + ctx := context.Background() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := cli.Create(ctx, &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-x.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-x", NodeName: "node-1"}, + }); err != nil { + t.Fatalf("seed adopted replica: %v", err) + } + + r := &AutoDiskfulReconciler{Store: st} + + disabled, err := r.disabledNodeSet(ctx) + if err != nil { + t.Fatalf("disabledNodeSet: %v", err) + } + + replicas, err := st.Resources().ListByDefinition(ctx, "pvc-x") + if err != nil || len(replicas) != 1 { + t.Fatalf("replicas %v, %v", replicas, err) + } + + replicas = append(replicas, apiv1.Resource{Name: "pvc-y", NodeName: "Node-1"}) + + if diskful, _ := splitDiskfulAndCandidates(replicas, disabled); len(diskful) != 0 { + t.Errorf("diskful %v; want both replicas on the evicted node dropped", diskful) + } +} diff --git a/internal/controller/node_spelling_lookups_test.go b/internal/controller/node_spelling_lookups_test.go new file mode 100644 index 00000000..7e0a14a9 --- /dev/null +++ b/internal/controller/node_spelling_lookups_test.go @@ -0,0 +1,243 @@ +// 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" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// The evacuation and LOST paths address a replica by its definition and +// node as the wire spells them, Pvc-Mix on Node-1, and must reach the CR +// the store wrote. The migration controller asks with the CR names from a +// replica's spec instead; for a node outside RFC 1123 the composite of +// those is not the hashed CR name, so that replica is found by its spec. +func TestEvacuationFindsTheReplicaUnderEverySpelling(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + node, created, askedRD, askedNode string + }{ + "folded name": {"Node-1", "NODE-1", "Pvc-Mix", "Node-1"}, + "CR names": {"Node-1", "node-1", "pvc-mix", "node-1"}, + "CR names outside RFC 1123": {"my_node", "my_node", "pvc-mix", naming.Key("my_node")}, + "registered spelling outside RFC 1123": {"my_node", "my_node", "Pvc-Mix", "my_node"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + + if tc.node != "Node-1" { + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(tc.node)}} + k8s.SetOriginalName(&node.ObjectMeta, tc.node) + + if err := cli.Create(ctx, &node); err != nil { + t.Fatalf("create node %s: %v", tc.node, err) + } + } + + if err := k8s.New(cli).Resources().Create(ctx, &apiv1.Resource{Name: "Pvc-Mix", NodeName: tc.created}); err != nil { + t.Fatalf("create the replica: %v", err) + } + + res := storedReplicas(t, cli)[0] + res.Status.Volumes = []blockstoriov1alpha1.ResourceVolumeStatus{{VolumeNumber: 0, DiskState: "UpToDate"}} + + if err := cli.Update(ctx, &res); err != nil { + t.Fatalf("mark the replica UpToDate: %v", err) + } + + r := &NodeReconciler{Client: cli, Store: k8s.New(cli)} + + upToDate, err := r.replicaUpToDate(ctx, tc.askedRD, tc.askedNode) + if err != nil || !upToDate { + t.Errorf("replicaUpToDate(%s, %s) = %v, %v; want the UpToDate replica %s", tc.askedRD, tc.askedNode, upToDate, err, res.Name) + } + + if err := r.pruneSource(ctx, tc.askedRD, tc.askedNode); err != nil { + t.Fatalf("pruneSource: %v", err) + } + + var left blockstoriov1alpha1.Resource + + err = cli.Get(ctx, types.NamespacedName{Name: res.Name}, &left) + if err == nil && left.DeletionTimestamp.IsZero() { + t.Errorf("pruneSource(%s, %s) left the replica %s in place", tc.askedRD, tc.askedNode, res.Name) + } + }) + } +} + +// A replica written before the store stored CR names keeps Node-1 in spec; +// the projection still reads the node and marks the replica Unknown when +// the node is offline. +func TestProjectionProjectsAReplicaStoredInTheRegisteredSpelling(t *testing.T) { + t.Parallel() + + projected := false + + cli := mixedCaseStand(t, interceptor.Funcs{ + SubResourcePatch: func(_ context.Context, _ client.Client, _ string, _ client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) error { + projected = true + + return nil + }, + }) + ctx := context.Background() + + if err := cli.Create(ctx, &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-1"}, + }); err != nil { + t.Fatalf("seed the older replica: %v", err) + } + + r := &ResourceStateProjectionReconciler{Client: cli} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "pvc-mix.node-1"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + if !projected { + t.Error("the replica stored as Node-1 on the offline node was not projected Unknown") + } +} + +// A satellite writes its snapshot entry under its own node name, node-1, +// while the snapshot's node list carries the registered spelling, Node-1. +// The phase gates see that entry, and the adopted backfill does not add a +// second one for the same node. +func TestSnapshotGatesMatchAnEntryInAnotherSpelling(t *testing.T) { + t.Parallel() + + entries := []blockstoriov1alpha1.SnapshotPerNodeStatus{{NodeName: "node-1", Ready: true}} + + if !allNodesReady(entries, []string{"Node-1"}) { + t.Error("allNodesReady missed node-1's entry for Node-1") + } + + if !hasNodeStatusEntry(entries, "Node-1") { + t.Error("hasNodeStatusEntry missed node-1's entry for Node-1") + } + + if allNodesReady(entries, []string{"Node-2"}) { + t.Error("allNodesReady took node-1's entry for Node-2") + } +} + +// A replica written before the store stored CR names keeps Pvc-Mix in spec; +// the orphan check still finds its definition and does not delete it. +func TestOrphanCheckFindsTheDefinitionOfAnOlderRow(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + + old := blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1", Finalizers: []string{satelliteResourceFinalizer}}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-1"}, + } + if err := cli.Create(ctx, &old); err != nil { + t.Fatalf("seed the older replica: %v", err) + } + + orphaned, err := (&ResourceReconciler{Client: cli}).handleOrphan(ctx, &old) + if err != nil || orphaned { + t.Fatalf("handleOrphan on the Pvc-Mix row = %v, %v; want it kept", orphaned, err) + } +} + +// The migration controller prunes the source replica by the CR names its +// destination's spec carries. For a node outside RFC 1123 their composite +// is not the hashed CR name the store wrote, so the source is found by its +// spec. +func TestMigrationPrunesASourceOnANodeOutsideRFC1123(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("my_node")}} + k8s.SetOriginalName(&node.ObjectMeta, "my_node") + + if err := cli.Create(ctx, &node); err != nil { + t.Fatalf("create node: %v", err) + } + + if err := k8s.New(cli).Resources().Create(ctx, &apiv1.Resource{Name: "Pvc-Mix", NodeName: "my_node"}); err != nil { + t.Fatalf("create the replica: %v", err) + } + + res := storedReplicas(t, cli)[0] + + dst := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.elsewhere"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: res.Spec.ResourceDefinitionName}, + } + + if err := (&ResourceMigrationReconciler{Client: cli}).pruneSrc(ctx, dst, res.Spec.NodeName); err != nil { + t.Fatalf("pruneSrc: %v", err) + } + + var left blockstoriov1alpha1.Resource + + err := cli.Get(ctx, types.NamespacedName{Name: res.Name}, &left) + if err == nil && left.DeletionTimestamp.IsZero() { + t.Errorf("pruneSrc(%s, %s) left the replica %s in place", res.Spec.ResourceDefinitionName, res.Spec.NodeName, res.Name) + } +} + +// pruneSrc never deletes the destination itself, whatever the marker that +// led to it says. +func TestPruneSrcLeavesTheDestinationItself(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + + if err := k8s.New(cli).Resources().Create(ctx, &apiv1.Resource{Name: "pvc-self", NodeName: "node-1"}); err != nil { + t.Fatalf("create the replica: %v", err) + } + + var dst blockstoriov1alpha1.Resource + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc-self.node-1"}, &dst); err != nil { + t.Fatalf("get the replica: %v", err) + } + + if err := (&ResourceMigrationReconciler{Client: cli}).pruneSrc(ctx, &dst, "Node-1"); err != nil { + t.Fatalf("pruneSrc: %v", err) + } + + var left blockstoriov1alpha1.Resource + if err := cli.Get(ctx, types.NamespacedName{Name: dst.Name}, &left); err != nil || !left.DeletionTimestamp.IsZero() { + t.Errorf("pruneSrc deleted the destination it was handed: %v", err) + } +} diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go new file mode 100644 index 00000000..6884e07a --- /dev/null +++ b/internal/controller/replica_of.go @@ -0,0 +1,70 @@ +// 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" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" +) + +// isReplicaOf reports whether res belongs to the definition rdName, in any +// spelling of it. The store writes a replica with the definition's CR +// name in spec, but a replica written or adopted before the store and +// linstor-migrate did that keeps the spelling it was given, Pvc-Mix say, so +// the two are compared the way the store addresses the definition. +func isReplicaOf(res *blockstoriov1alpha1.Resource, rdName string) bool { + return naming.Same(res.Spec.ResourceDefinitionName, rdName) +} + +// getReplica reads the replica of definition rd on node into out, whichever +// spellings the caller and the stored row use. The store names the CR from +// the registered spellings, so the folded composite name finds it for every +// name inside RFC 1123; a name outside it hashes case-sensitively, and a +// replica written in another spelling is then found by its spec instead. +// It answers NotFound when there is no such replica. +func getReplica(ctx context.Context, c client.Reader, rd, node string, out *blockstoriov1alpha1.Resource) error { + err := c.Get(ctx, client.ObjectKey{Name: naming.Key(rd + "." + node)}, out) + if err == nil || !apierrors.IsNotFound(err) { + return err + } + + var list blockstoriov1alpha1.ResourceList + + err = c.List(ctx, &list) + if err != nil { + return err + } + + for i := range list.Items { + item := &list.Items[i] + if naming.Same(item.Spec.ResourceDefinitionName, rd) && naming.Same(item.Spec.NodeName, node) { + *out = *item + + return nil + } + } + + return apierrors.NewNotFound(schema.GroupResource{Group: blockstoriov1alpha1.SchemeGroupVersion.Group, Resource: "resources"}, rd+"."+node) +} diff --git a/internal/controller/resource_controller.go b/internal/controller/resource_controller.go index 53a0597b..1dbf359c 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -40,6 +40,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/drbd" "github.com/cozystack/blockstor/pkg/effectiveprops" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -282,7 +283,7 @@ func (r *ResourceReconciler) handleOrphan(ctx context.Context, target *blockstor var rd blockstoriov1alpha1.ResourceDefinition - err := r.Get(ctx, client.ObjectKey{Name: target.Spec.ResourceDefinitionName}, &rd) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(target.Spec.ResourceDefinitionName)}, &rd) if err == nil { return false, nil } @@ -336,7 +337,7 @@ func (r *ResourceReconciler) runApply(ctx context.Context, target *blockstoriov1 peers := make([]blockstoriov1alpha1.Resource, 0, len(resList.Items)) for i := range resList.Items { - if resList.Items[i].Spec.ResourceDefinitionName == target.Spec.ResourceDefinitionName { + if naming.Same(resList.Items[i].Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { peers = append(peers, resList.Items[i]) } } @@ -483,7 +484,7 @@ func (r *ResourceReconciler) firstAvailablePool(ctx context.Context, nodeName st } for i := range pools { - if pools[i].NodeName != nodeName { + if !naming.Same(pools[i].NodeName, nodeName) { continue } @@ -525,7 +526,7 @@ func (r *ResourceReconciler) stripLegacyFinalizer(ctx context.Context, target *b func (r *ResourceReconciler) lookupRD(ctx context.Context, name string) (*blockstoriov1alpha1.ResourceDefinition, error) { var rd blockstoriov1alpha1.ResourceDefinition - err := r.Get(ctx, client.ObjectKey{Name: name}, &rd) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(name)}, &rd) if err != nil { if errors.IsNotFound(err) { return nil, nil //nolint:nilnil // intentional: caller treats nil RD as no-volume push @@ -794,7 +795,7 @@ func (r *ResourceReconciler) allocateResourceSpecFields(ctx context.Context, tar func (r *ResourceReconciler) rdInitializedForResource(ctx context.Context, rdName string) (bool, bool, error) { var rd blockstoriov1alpha1.ResourceDefinition - err := r.apiReader().Get(ctx, client.ObjectKey{Name: rdName}, &rd) + err := r.apiReader().Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd) if err != nil { if errors.IsNotFound(err) { return false, false, nil @@ -1088,7 +1089,7 @@ func pickSeedFromPeers(peers []blockstoriov1alpha1.Resource, targetName string, candidates := make([]blockstoriov1alpha1.Resource, 0, len(peers)) for i := range peers { - if peers[i].Name == targetName { + if naming.Same(peers[i].Name, targetName) { continue } @@ -1170,7 +1171,7 @@ func anyProvenDataBearingDiskfulPeer(peers []blockstoriov1alpha1.Resource, targe func scanDataBearingDiskfulPeers(peers []blockstoriov1alpha1.Resource, targetName string, requireObservedGI bool) bool { for i := range peers { - if peers[i].Name == targetName { + if naming.Same(peers[i].Name, targetName) { continue } @@ -1386,7 +1387,7 @@ func (r *ResourceReconciler) patchRDInitialized(ctx context.Context, rdName stri Kind: resourceDefinitionKindName, APIVersion: blockstoriov1alpha1.GroupVersion.String(), }, - ObjectMeta: metav1.ObjectMeta{Name: rdName}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(rdName)}, } body := map[string]any{patchKeySpec: map[string]any{"initialized": true}} @@ -1465,7 +1466,7 @@ func (r *ResourceReconciler) ensureRDVolumeMinors(ctx context.Context, target *b var rd blockstoriov1alpha1.ResourceDefinition - err := reader.Get(ctx, client.ObjectKey{Name: rdName}, &rd) + err := reader.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd) if err != nil { if errors.IsNotFound(err) { // RD absent (unit-test fast path / rd-delete in flight). @@ -1504,7 +1505,7 @@ func (r *ResourceReconciler) ensureRDVolumeMinors(ctx context.Context, target *b // return whatever it stamped — the next reconcile observes // the committed values uniformly. var fresh blockstoriov1alpha1.ResourceDefinition - if gerr := reader.Get(ctx, client.ObjectKey{Name: rdName}, &fresh); gerr == nil { + if gerr := reader.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &fresh); gerr == nil { return false, vol0MinorOf(&fresh), nil } } @@ -1688,7 +1689,7 @@ func (r *ResourceReconciler) patchRDVolumeMinors(ctx context.Context, rd *blocks Kind: resourceDefinitionKindName, APIVersion: blockstoriov1alpha1.GroupVersion.String(), }, - ObjectMeta: metav1.ObjectMeta{Name: rd.Name}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(rd.Name)}, } return r.Patch(ctx, patchTarget, client.RawPatch(types.MergePatchType, patchBytes)) @@ -1727,7 +1728,7 @@ func (r *ResourceReconciler) allocatePortForNode(ctx context.Context, target *bl // seed. Returns nil when the RD is absent or sets no preference. func (r *ResourceReconciler) rdPreferredPort(ctx context.Context, rdName string) *int32 { var rd blockstoriov1alpha1.ResourceDefinition - if err := r.apiReader().Get(ctx, client.ObjectKey{Name: rdName}, &rd); err != nil { + if err := r.apiReader().Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd); err != nil { return nil } @@ -1761,16 +1762,16 @@ func (r *ResourceReconciler) hostingNodesForRD(ctx context.Context, rdName strin out := make([]string, 0, len(list.Items)) for i := range list.Items { - if list.Items[i].Spec.ResourceDefinitionName != rdName { + if !isReplicaOf(&list.Items[i], rdName) { continue } node := list.Items[i].Spec.NodeName - if seen[node] { + if seen[naming.Key(node)] { continue } - seen[node] = true + seen[naming.Key(node)] = true out = append(out, node) } @@ -1856,7 +1857,7 @@ func (r *ResourceReconciler) takenPortsOnNode(ctx context.Context, nodeName, sel for i := range resList.Items { res := &resList.Items[i] - if res.Spec.NodeName != nodeName || res.Name == selfName { + if !naming.Same(res.Spec.NodeName, nodeName) || res.Name == selfName { continue } @@ -1890,7 +1891,7 @@ func (r *ResourceReconciler) takenMinorsCluster(ctx context.Context, selfRD stri for i := range rdList.Items { rd := &rdList.Items[i] - if rd.Name == selfRD { + if naming.Same(rd.Name, selfRD) { continue } @@ -1969,7 +1970,7 @@ func (r *ResourceReconciler) nodeRange( defLow, defHigh int32, ) (int32, int32, error) { var node blockstoriov1alpha1.Node - if err := r.Get(ctx, client.ObjectKey{Name: nodeName}, &node); err != nil { + if err := r.Get(ctx, client.ObjectKey{Name: naming.Key(nodeName)}, &node); err != nil { if errors.IsNotFound(err) { return defLow, defHigh, nil } @@ -2215,7 +2216,7 @@ func (r *ResourceReconciler) collectTakenNodeIDs(ctx context.Context, target *bl continue } - if res.Spec.ResourceDefinitionName != target.Spec.ResourceDefinitionName { + if !naming.Same(res.Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { continue } @@ -2284,7 +2285,7 @@ func (r *ResourceReconciler) requestsForRD(ctx context.Context, rdName, excludeN out := make([]reconcile.Request, 0, len(resList.Items)) for i := range resList.Items { - if resList.Items[i].Spec.ResourceDefinitionName != rdName { + if !naming.Same(resList.Items[i].Spec.ResourceDefinitionName, rdName) { continue } diff --git a/internal/controller/resource_migration_controller.go b/internal/controller/resource_migration_controller.go index 283bd9b3..7556192e 100644 --- a/internal/controller/resource_migration_controller.go +++ b/internal/controller/resource_migration_controller.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" ) // MigratingFromProp is the per-Resource Spec.Props key the REST @@ -112,6 +113,18 @@ func (r *ResourceMigrationReconciler) Reconcile(ctx context.Context, req ctrl.Re return ctrl.Result{}, nil } + // A marker naming the destination's own node, in any spelling, names + // the destination itself: pruning the "source" would delete the only + // copy the migration made. Nothing writes such a marker now, but one + // persisted before cannot be trusted either, so it is dropped and the + // replica left alone. + if naming.Same(srcNode, dst.Spec.NodeName) { + logger.Info("refusing a migration onto its own source; clearing the marker without pruning", + "resource", dst.Name, "src", srcNode) + + return ctrl.Result{}, r.clearMigratingFrom(ctx, &dst) + } + if !allVolumesUpToDate(&dst) { // dst still syncing. Keep src alive and check back later. // SetupWithManager also wires a watch on Resource Status @@ -122,9 +135,7 @@ func (r *ResourceMigrationReconciler) Reconcile(ctx context.Context, req ctrl.Re return ctrl.Result{RequeueAfter: migrationRequeueInterval}, nil } - srcName := resourceCRDName(dst.Spec.ResourceDefinitionName, srcNode) - - err = r.pruneSrc(ctx, srcName) + err = r.pruneSrc(ctx, &dst, srcNode) if err != nil { return ctrl.Result{}, err } @@ -135,7 +146,7 @@ func (r *ResourceMigrationReconciler) Reconcile(ctx context.Context, req ctrl.Re } logger.Info("migration complete: src pruned, dst UpToDate", - "resource", dst.Name, "src", srcName) + "resource", dst.Name, "src", srcNode) return ctrl.Result{}, nil } @@ -144,10 +155,10 @@ func (r *ResourceMigrationReconciler) Reconcile(ctx context.Context, req ctrl.Re // swallowed — the operator may have removed it manually, or a // previous reconcile pass completed the delete but failed to clear // the prop afterwards. Either way, the reconciler is monotonic. -func (r *ResourceMigrationReconciler) pruneSrc(ctx context.Context, srcName string) error { +func (r *ResourceMigrationReconciler) pruneSrc(ctx context.Context, dst *blockstoriov1alpha1.Resource, srcNode string) error { var src blockstoriov1alpha1.Resource - err := r.Get(ctx, client.ObjectKey{Name: srcName}, &src) + err := getReplica(ctx, r.Client, dst.Spec.ResourceDefinitionName, srcNode, &src) if err != nil { if errors.IsNotFound(err) { return nil @@ -156,6 +167,11 @@ func (r *ResourceMigrationReconciler) pruneSrc(ctx context.Context, srcName stri return err } + // However the marker spells it, the source is never the destination. + if src.UID == dst.UID && src.Name == dst.Name { + return nil + } + err = r.Delete(ctx, &src) if err != nil && !errors.IsNotFound(err) { return err diff --git a/internal/controller/resource_migration_controller_test.go b/internal/controller/resource_migration_controller_test.go index 47c29d9a..e81954e3 100644 --- a/internal/controller/resource_migration_controller_test.go +++ b/internal/controller/resource_migration_controller_test.go @@ -241,3 +241,45 @@ func TestMigrationReconcilerSrcAlreadyGone(t *testing.T) { t.Errorf("prop not cleared after missing-src reconcile") } } + +// A marker that names the destination's own node in another spelling names +// the destination itself. The reconcile drops the marker and leaves the +// replica, the only copy the migration made, in place. +func TestMigrationReconcilerRefusesAMarkerNamingTheDestination(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := newScheme(t) + + dst := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: "pvc", + NodeName: "node-1", + Props: map[string]string{controllerpkg.MigratingFromProp: "Node-1"}, + }, + Status: blockstoriov1alpha1.ResourceStatus{ + Volumes: []blockstoriov1alpha1.ResourceVolumeStatus{{VolumeNumber: 0, DiskState: "UpToDate"}}, + }, + } + + cli := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&blockstoriov1alpha1.Resource{}). + WithObjects(dst). + Build() + + rec := &controllerpkg.ResourceMigrationReconciler{Client: cli, Scheme: scheme} + + if _, err := rec.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "pvc.node-1"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + after := &blockstoriov1alpha1.Resource{} + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc.node-1"}, after); err != nil { + t.Fatalf("the destination is gone: %v", err) + } + + if _, ok := after.Spec.Props[controllerpkg.MigratingFromProp]; ok { + t.Error("the marker naming the destination was left set") + } +} diff --git a/internal/controller/resource_state_projection_controller.go b/internal/controller/resource_state_projection_controller.go index 89dda83f..96f5c258 100644 --- a/internal/controller/resource_state_projection_controller.go +++ b/internal/controller/resource_state_projection_controller.go @@ -34,6 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" ) // ResourceStateProjectionFieldOwner is the SSA field-manager identity @@ -119,7 +120,9 @@ func (r *ResourceStateProjectionReconciler) Reconcile(ctx context.Context, req c var node blockstoriov1alpha1.Node - err = r.Get(ctx, types.NamespacedName{Name: res.Spec.NodeName}, &node) + // The Node CR is named from the folded name; a replica written before + // the store stored CR names may still carry the registered spelling. + err = r.Get(ctx, types.NamespacedName{Name: naming.Key(res.Spec.NodeName)}, &node) if err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil @@ -268,7 +271,7 @@ func (r *ResourceStateProjectionReconciler) enqueueResourcesForNode(ctx context. out := make([]reconcile.Request, 0, len(resList.Items)) for i := range resList.Items { - if resList.Items[i].Spec.NodeName != node.Name { + if !naming.Same(resList.Items[i].Spec.NodeName, node.Name) { continue } diff --git a/internal/controller/resourcedefinition_controller.go b/internal/controller/resourcedefinition_controller.go index a501ed7d..488405f5 100644 --- a/internal/controller/resourcedefinition_controller.go +++ b/internal/controller/resourcedefinition_controller.go @@ -21,7 +21,6 @@ package controller import ( "context" stderrors "errors" - "maps" "slices" "strconv" "strings" @@ -42,8 +41,8 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // Bug148ResizePendingAnnotationPrefix is the per-volume annotation @@ -697,7 +696,7 @@ func (r *ResourceDefinitionReconciler) listReplicasDirect(ctx context.Context, r out := make([]apiv1.Resource, 0, len(crdList.Items)) for i := range crdList.Items { - if crdList.Items[i].Spec.ResourceDefinitionName != rdName { + if !isReplicaOf(&crdList.Items[i], rdName) { continue } @@ -847,7 +846,7 @@ func quorumPolicy(diskful, diskless int) string { func (r *ResourceDefinitionReconciler) createWitness(ctx context.Context, rd *blockstoriov1alpha1.ResourceDefinition, existing []apiv1.Resource) (bool, error) { hostingReplica := map[string]bool{} for i := range existing { - hostingReplica[existing[i].NodeName] = true + hostingReplica[naming.Key(existing[i].NodeName)] = true } // Bug 261: route through the RD-aware selector so a stale @@ -941,7 +940,7 @@ func (r *ResourceDefinitionReconciler) rollbackWitnessIfRDGone(ctx context.Conte var fresh blockstoriov1alpha1.ResourceDefinition - err := r.APIReader.Get(ctx, client.ObjectKey{Name: rdName}, &fresh) + err := r.APIReader.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &fresh) if err == nil && fresh.DeletionTimestamp.IsZero() { // RD is still live and not mid-delete; witness is valid. return @@ -992,7 +991,7 @@ func (r *ResourceDefinitionReconciler) resolveRDLayerStack(ctx context.Context, reader := r.directOrCached() var rg blockstoriov1alpha1.ResourceGroup - if err := reader.Get(ctx, client.ObjectKey{Name: k8s.Name(rd.Spec.ResourceGroupName)}, &rg); err != nil { + if err := reader.Get(ctx, client.ObjectKey{Name: naming.Key(rd.Spec.ResourceGroupName)}, &rg); err != nil { return apiv1.DefaultLayerStack() } @@ -1111,7 +1110,7 @@ func (r *ResourceDefinitionReconciler) setQuorum(ctx context.Context, rd *blocks } // Refetch and retry. - err = r.Get(ctx, client.ObjectKey{Name: rd.Name}, rd) + err = r.Get(ctx, client.ObjectKey{Name: naming.Key(rd.Name)}, rd) if err != nil { return err } @@ -1279,7 +1278,9 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( hostingReplica map[string]bool, ) (string, error) { excluded := make(map[string]bool, len(hostingReplica)) - maps.Copy(excluded, hostingReplica) + for name, hosting := range hostingReplica { + excluded[naming.Key(name)] = hosting + } if rdName != "" { // Defense-in-depth: re-probe the store for diskful Resources @@ -1297,7 +1298,7 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( continue } - excluded[live[i].NodeName] = true + excluded[naming.Key(live[i].NodeName)] = true } } @@ -1309,7 +1310,7 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( candidates := make([]string, 0, len(nodes)) for i := range nodes { - if excluded[nodes[i].Name] { + if excluded[naming.Key(nodes[i].Name)] { continue } @@ -1421,9 +1422,11 @@ func (r *ResourceDefinitionReconciler) dropStrandedReplicas( return live, nil } -// nodeSets returns (disabled, known): the set of node names flagged -// EVICTED / LOST and the set of ALL node names the store currently -// lists. Bug 385: the RD-level witness / quorum decision must treat +// nodeSets returns (disabled, known): the set of nodes flagged +// EVICTED / LOST and the set of ALL nodes the store currently lists, +// both keyed by CR name (k8s.Name). A replica names its node by the +// CR name or by the spelling it was registered under, and the node +// list by the registered one, so only the CR name compares them. Bug 385: the RD-level witness / quorum decision must treat // replicas on disabled nodes as draining placements, not live ones — // mirroring the placer's `disabledNodes` semantic. Bug-024 extends // the same idea to nodes that are gone entirely: a replica whose @@ -1442,10 +1445,10 @@ func (r *ResourceDefinitionReconciler) nodeSets(ctx context.Context) (map[string known := make(map[string]struct{}, len(nodes)) for i := range nodes { - known[nodes[i].Name] = struct{}{} + known[naming.Key(nodes[i].Name)] = struct{}{} if isDisabledNode(&nodes[i]) { - disabled[nodes[i].Name] = struct{}{} + disabled[naming.Key(nodes[i].Name)] = struct{}{} } } @@ -1468,7 +1471,7 @@ func (r *ResourceDefinitionReconciler) confirmGhostReplicas( var live, ghosts []apiv1.Resource for i := range replicas { - if _, ok := known[replicas[i].NodeName]; ok { + if _, ok := known[naming.Key(replicas[i].NodeName)]; ok { live = append(live, replicas[i]) continue @@ -1493,13 +1496,6 @@ func (r *ResourceDefinitionReconciler) confirmGhostReplicas( return live, ghosts, nil } -// linstorNameAnnotation mirrors pkg/store/k8s.AnnotationLinstorName. -// The literal is duplicated (same pattern as snapshotGroupIDLabel in -// snapshot_controller.go) to avoid a controller→store/k8s dependency. -// Node CRDs whose LINSTOR name needed slugifying carry the original -// wire name here; probeNodeDirect matches against both spellings. -const linstorNameAnnotation = "blockstor.io/linstor-name" - // nodeProbe is the answer probeNodeDirect returns about a node's // authoritative state. type nodeProbe struct { @@ -1522,9 +1518,9 @@ type nodeProbe struct { // pinned node against the store right after the write. // // Production path: APIReader (direct apiserver reads, no cache). -// LINSTOR node names are matched case-insensitively against both the -// CRD name and the preserved original-name annotation, the same -// resolution pkg/store/k8s applies. Unit tests construct the +// A LINSTOR node name matches the CRD it maps to (k8s.Name), the same +// resolution pkg/store/k8s applies, so My_Node and my_node stay two +// nodes. Unit tests construct the // reconciler without APIReader — the Store fallback is authoritative // there (InMemory has no cache to lag). func (r *ResourceDefinitionReconciler) probeNodeDirect(ctx context.Context, wireName string) (nodeProbe, error) { @@ -1550,7 +1546,7 @@ func (r *ResourceDefinitionReconciler) probeNodeDirect(ctx context.Context, wire } for i := range list.Items { - if !nodeCRDMatchesWireName(&list.Items[i], wireName) { + if list.Items[i].Name != naming.Key(wireName) { continue } @@ -1563,20 +1559,6 @@ func (r *ResourceDefinitionReconciler) probeNodeDirect(ctx context.Context, wire return nodeProbe{}, nil } -// nodeCRDMatchesWireName reports whether a Node CRD addresses the -// given LINSTOR wire name: either directly via metadata.name or via -// the preserved original-name annotation (slugified names). LINSTOR -// identifiers are case-insensitive, so both comparisons fold case. -func nodeCRDMatchesWireName(node *blockstoriov1alpha1.Node, wireName string) bool { - if strings.EqualFold(node.Name, wireName) { - return true - } - - original, ok := node.Annotations[linstorNameAnnotation] - - return ok && strings.EqualFold(original, wireName) -} - // splitByDisabledNode partitions replicas into (live, stranded) by the // disabled-node set. `live` is hosted on healthy (non-EVICTED/-LOST) // nodes and drives the witness / quorum decision; `stranded` sits on a @@ -1585,7 +1567,7 @@ func splitByDisabledNode(replicas []apiv1.Resource, disabled map[string]struct{} var live, stranded []apiv1.Resource for i := range replicas { - if _, off := disabled[replicas[i].NodeName]; off { + if _, off := disabled[naming.Key(replicas[i].NodeName)]; off { stranded = append(stranded, replicas[i]) continue @@ -1669,7 +1651,7 @@ func (r *ResourceDefinitionReconciler) stampResizePending(ctx context.Context, r } for i := range resList.Items { - if resList.Items[i].Spec.ResourceDefinitionName != rd.Name { + if !isReplicaOf(&resList.Items[i], rd.Name) { continue } @@ -1923,6 +1905,8 @@ func (r *ResourceDefinitionReconciler) enqueueRDForResource(_ context.Context, o } return []reconcile.Request{ - {NamespacedName: types.NamespacedName{Name: res.Spec.ResourceDefinitionName}}, + // The RD CR lives under the folded name; the replica keeps the + // spelling it was written with. + {NamespacedName: types.NamespacedName{Name: naming.Key(res.Spec.ResourceDefinitionName)}}, } } diff --git a/internal/controller/resourcedefinition_layer_stack.go b/internal/controller/resourcedefinition_layer_stack.go index a1423ed4..fcfe677f 100644 --- a/internal/controller/resourcedefinition_layer_stack.go +++ b/internal/controller/resourcedefinition_layer_stack.go @@ -36,7 +36,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // layerStackNotInheritedReason is the Event reason for an RD that kept @@ -122,7 +122,7 @@ func (r *ResourceDefinitionReconciler) readGroup( ) (*blockstoriov1alpha1.ResourceGroup, error) { var rg blockstoriov1alpha1.ResourceGroup - err := r.directOrCached().Get(ctx, client.ObjectKey{Name: k8s.Name(rd.Spec.ResourceGroupName)}, &rg) + err := r.directOrCached().Get(ctx, client.ObjectKey{Name: naming.Key(rd.Spec.ResourceGroupName)}, &rg) if apierrors.IsNotFound(err) { return nil, nil //nolint:nilnil // no group is an answer, not an error } @@ -225,7 +225,7 @@ func (r *ResourceDefinitionReconciler) rdHoldsData(ctx context.Context, rdName s for i := range list.Items { res := &list.Items[i] - if res.Spec.ResourceDefinitionName != rdName { + if !isReplicaOf(res, rdName) { continue } @@ -276,7 +276,7 @@ func (r *ResourceDefinitionReconciler) enqueueRDsInGroup(ctx context.Context, ob for i := range rdList.Items { group := rdList.Items[i].Spec.ResourceGroupName - if group != "" && k8s.Name(group) == obj.GetName() { + if group != "" && naming.Key(group) == obj.GetName() { out = append(out, reconcile.Request{NamespacedName: types.NamespacedName{Name: rdList.Items[i].Name}}) } } diff --git a/internal/controller/resourcedefinition_layer_stack_test.go b/internal/controller/resourcedefinition_layer_stack_test.go index e042e52b..936e5b1a 100644 --- a/internal/controller/resourcedefinition_layer_stack_test.go +++ b/internal/controller/resourcedefinition_layer_stack_test.go @@ -193,6 +193,8 @@ func TestPinLayerStackKeepsTheAppliedStackOfAnRDThatHoldsData(t *testing.T) { for name, replica := range map[string]*blockstoriov1alpha1.Resource{ "metadata created": withMetadata(pinReplica("pvc-pin", "n1")), "device reported": withDevice(pinReplica("pvc-pin", "n1")), + // A replica written before the store kept CR names in spec. + "metadata created, stored in another spelling": withMetadata(pinReplica("Pvc-Pin", "n1")), } { t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/internal/controller/resourcegroup_controller.go b/internal/controller/resourcegroup_controller.go index 3afcde35..3ae2d084 100644 --- a/internal/controller/resourcegroup_controller.go +++ b/internal/controller/resourcegroup_controller.go @@ -28,6 +28,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" ) @@ -94,7 +95,7 @@ func (r *ResourceGroupReconciler) Reconcile(ctx context.Context, req ctrl.Reques } for i := range rds { - if rds[i].ResourceGroupName != rg.Name { + if !naming.Same(rds[i].ResourceGroupName, rg.Name) { continue } diff --git a/internal/controller/rg_rebalance_controller.go b/internal/controller/rg_rebalance_controller.go index 9ec5c7c2..fa37098a 100644 --- a/internal/controller/rg_rebalance_controller.go +++ b/internal/controller/rg_rebalance_controller.go @@ -30,6 +30,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" ) @@ -228,7 +229,7 @@ func (r *RGRebalanceReconciler) rebalanceChildRDs(ctx context.Context, rg *apiv1 now := r.now() for i := range rds { - if rds[i].ResourceGroupName != rg.Name { + if !naming.Same(rds[i].ResourceGroupName, rg.Name) { continue } @@ -290,7 +291,7 @@ func (r *RGRebalanceReconciler) replaySpawnShortfalls(ctx context.Context, rg *a p := placer.New(r.Store) for i := range rds { - if rds[i].ResourceGroupName != rg.Name { + if !naming.Same(rds[i].ResourceGroupName, rg.Name) { continue } @@ -462,17 +463,17 @@ func (r *RGRebalanceReconciler) refreshOfflineTracker(ctx context.Context) { seen := map[string]bool{} for i := range nodes { - seen[nodes[i].Name] = true + seen[naming.Key(nodes[i].Name)] = true offline := nodes[i].ConnectionStatus == apiv1.NodeTypeOffline if !offline { - delete(r.nodeOfflineSince, nodes[i].Name) + delete(r.nodeOfflineSince, naming.Key(nodes[i].Name)) continue } - if _, already := r.nodeOfflineSince[nodes[i].Name]; !already { - r.nodeOfflineSince[nodes[i].Name] = now + if _, already := r.nodeOfflineSince[naming.Key(nodes[i].Name)]; !already { + r.nodeOfflineSince[naming.Key(nodes[i].Name)] = now } } diff --git a/internal/controller/snapshot_controller.go b/internal/controller/snapshot_controller.go index 265db21b..fd3aec20 100644 --- a/internal/controller/snapshot_controller.go +++ b/internal/controller/snapshot_controller.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" ) // SnapshotReconciler orchestrates the Bug-351 cross-satellite @@ -236,7 +237,7 @@ func (r *SnapshotReconciler) backfillAdoptedSnapshot( // 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 { + if naming.Same(entries[i].NodeName, node) { return true } } @@ -688,13 +689,16 @@ func allTargetsMatch( targets []string, pred func(blockstoriov1alpha1.SnapshotPerNodeStatus) bool, ) bool { + // A satellite writes its entry under its own node name, and the + // snapshot's node list carries the registered spelling: Node-1 and + // node-1 are one node. byNode := make(map[string]blockstoriov1alpha1.SnapshotPerNodeStatus, len(entries)) for i := range entries { - byNode[entries[i].NodeName] = entries[i] + byNode[naming.Key(entries[i].NodeName)] = entries[i] } for _, t := range targets { - entry, ok := byNode[t] + entry, ok := byNode[naming.Key(t)] if !ok { return false } @@ -777,7 +781,7 @@ func (r *SnapshotReconciler) fetchSiblings( selfSeen := false for i := range list.Items { - if list.Items[i].Name == snap.Name { + if naming.Same(list.Items[i].Name, snap.Name) { selfSeen = true // Use the freshly-fetched copy for self so the diff --git a/internal/controller/snapshot_timeout.go b/internal/controller/snapshot_timeout.go index 7f623f11..4a39a8e7 100644 --- a/internal/controller/snapshot_timeout.go +++ b/internal/controller/snapshot_timeout.go @@ -32,6 +32,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // snapshotSuspendDeadline bounds how long a Snapshot may stay frozen @@ -324,19 +325,16 @@ func (r *SnapshotReconciler) siblingReplicasUpToDate( return false, "", errors.Wrap(err, "list Resources for UpToDate gate") } - targets := make(map[string]struct{}, len(snap.Spec.Nodes)) - for _, n := range snap.Spec.Nodes { - targets[n] = struct{}{} - } - for i := range resList.Items { res := &resList.Items[i] - if res.Spec.ResourceDefinitionName != snap.Spec.ResourceDefinitionName { + if !naming.Same(res.Spec.ResourceDefinitionName, snap.Spec.ResourceDefinitionName) { continue } - if _, targeted := targets[res.Spec.NodeName]; !targeted { + // The node list carries the registered spelling, the replica its + // node's CR name. + if !slices.ContainsFunc(snap.Spec.Nodes, func(n string) bool { return naming.Same(n, res.Spec.NodeName) }) { continue } diff --git a/internal/controller/stored_cr_names_test.go b/internal/controller/stored_cr_names_test.go new file mode 100644 index 00000000..90880737 --- /dev/null +++ b/internal/controller/stored_cr_names_test.go @@ -0,0 +1,345 @@ +// 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" + "slices" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// mixedCaseStand is a fake API server with a definition registered as +// Pvc-Mix and a node registered as Node-1 (offline), and one replica created +// through the store per entry of replicas, each a pair of the spellings the +// caller used for the definition and the node. +func mixedCaseStand(t *testing.T, funcs interceptor.Funcs, replicas ...[2]string) client.Client { + t.Helper() + + rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Pvc-Mix")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") + + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Node-1")}} + k8s.SetOriginalName(&node.ObjectMeta, "Node-1") + node.Status.ConnectionStatus = blockstoriov1alpha1.NodeConnectionStatusOffline + + base := fake.NewClientBuilder().WithScheme(bug048Scheme(t)).WithObjects(&rd, &node).Build() + + for _, names := range replicas { + if err := k8s.New(base).Resources().Create(context.Background(), + &apiv1.Resource{Name: names[0], NodeName: names[1]}); err != nil { + t.Fatalf("create %v through the store: %v", names, err) + } + } + + return interceptor.NewClient(base, funcs) +} + +func storedReplicas(t *testing.T, cli client.Client) []blockstoriov1alpha1.Resource { + t.Helper() + + var list blockstoriov1alpha1.ResourceList + if err := cli.List(context.Background(), &list); err != nil { + t.Fatalf("list replicas: %v", err) + } + + return list.Items +} + +// The controller does not take a replica of a mixed-case definition for an +// orphan: it finds the definition by the stored name. With the registered +// spelling in spec the Get answered NotFound and the replica was deleted as +// soon as the satellite had stamped its finalizer. +func TestOrphanCheckFindsTheMixedCaseDefinition(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}, [2]string{"PVC-MIX", "NODE-1"}) + res := storedReplicas(t, cli)[0] + res.Finalizers = append(res.Finalizers, satelliteResourceFinalizer) + + r := &ResourceReconciler{Client: cli} + + orphaned, err := r.handleOrphan(context.Background(), &res) + if err != nil { + t.Fatalf("handleOrphan: %v", err) + } + + if orphaned { + t.Fatal("a replica of Pvc-Mix was taken for an orphan and deleted") + } +} + +// A replica written before the store stored the node's CR name, by +// `r c pvc-old node-1` over a node registered as Node-1, carries node-1. A +// replica created now as NODE-1 stores the CR name node-1, while an older +// replica written before that keeps Node-1. The port allocator has to see +// the older replica's port when it picks for the new one. +func TestTakenPortsSeeAReplicaCreatedInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}, [2]string{"pvc-new", "NODE-1"}) + ctx := context.Background() + + port := int32(7001) + + // As main wrote it before the store stored CR names: `r c pvc-old Node-1` + // kept the registered spelling. + if err := cli.Create(ctx, &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-old.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: "pvc-old", NodeName: "Node-1", DRBDPort: &port, + }, + }); err != nil { + t.Fatalf("seed the older replica: %v", err) + } + + var self blockstoriov1alpha1.Resource + + for _, res := range storedReplicas(t, cli) { + if res.Name != "pvc-old.node-1" { + self = res + } + } + + taken, err := (&ResourceReconciler{Client: cli}).takenPortsOnNode(ctx, self.Spec.NodeName, self.Name) + if err != nil { + t.Fatalf("takenPortsOnNode: %v", err) + } + + if !slices.Contains(taken, port) { + t.Errorf("ports taken on %s = %v, want the %d of the older replica on Node-1", self.Spec.NodeName, taken, port) + } +} + +// The state projection finds the node of a replica created in a mixed-case +// node spelling, and a node event reaches the replica: an offline node +// turns its replicas Unknown. With the registered spelling in spec the +// Node Get answered NotFound and the replica was never projected. +func TestProjectionFollowsTheNodeOfAMixedCaseReplica(t *testing.T) { + t.Parallel() + + projected := false + + cli := mixedCaseStand(t, interceptor.Funcs{ + SubResourcePatch: func(_ context.Context, _ client.Client, _ string, _ client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) error { + projected = true + + return nil + }, + }, [2]string{"Pvc-Mix", "NODE-1"}) + + res := storedReplicas(t, cli)[0] + r := &ResourceStateProjectionReconciler{Client: cli} + + var node blockstoriov1alpha1.Node + if err := cli.Get(context.Background(), types.NamespacedName{Name: "node-1"}, &node); err != nil { + t.Fatalf("get the node: %v", err) + } + + reqs := r.enqueueResourcesForNode(context.Background(), &node) + if len(reqs) != 1 || reqs[0].Name != res.Name { + t.Errorf("node event enqueued %v, want %s", reqs, res.Name) + } + + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: res.Name}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + if !projected { + t.Error("the replica on the offline Node-1 was not projected Unknown") + } +} + +// naming.Same tells two spellings of one definition from two definitions. +// The peer list, the node-id allocation, the sibling fan-out and the +// snapshot's sibling check use it for replicas written or adopted before +// the store kept CR names in spec, which may carry the display spelling. +func TestSameDefinitionFoldsCase(t *testing.T) { + t.Parallel() + + if !naming.Same("Pvc-Mix", "pvc-mix") { + t.Error("Pvc-Mix and pvc-mix read as two definitions") + } + + if naming.Same("pvc-mix", "pvc-other") { + t.Error("pvc-mix and pvc-other read as one definition") + } +} + +// The snapshot gate refuses a replica that is not UpToDate. The snapshot's +// node list carries the registered spelling, Node-1, while the replica stores +// the CR name node-1, and the gate has to match the two or it skips the +// replica and snapshots torn bytes. +func TestSnapshotGateSeesAReplicaUnderItsNodesCRName(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}, [2]string{"Pvc-Mix", "NODE-1"}) + ctx := context.Background() + + res := storedReplicas(t, cli)[0] + res.Status.Volumes = []blockstoriov1alpha1.ResourceVolumeStatus{{VolumeNumber: 0, DiskState: "Outdated"}} + + if err := cli.Update(ctx, &res); err != nil { + t.Fatalf("mark the replica Outdated: %v", err) + } + + snap := &blockstoriov1alpha1.Snapshot{Spec: blockstoriov1alpha1.SnapshotSpec{ + ResourceDefinitionName: "pvc-mix", SnapshotName: "snap", Nodes: []string{"Node-1"}, + }} + + ok, node, err := (&SnapshotReconciler{Client: cli}).siblingReplicasUpToDate(ctx, snap) + if err != nil { + t.Fatalf("siblingReplicasUpToDate: %v", err) + } + + if ok { + t.Errorf("the gate passed an Outdated replica on Node-1 stored as %s", res.Spec.NodeName) + } + + if node != res.Spec.NodeName { + t.Errorf("offending node %q, want %q", node, res.Spec.NodeName) + } +} + +// The auto-diskful promotion picks a pool on the replica's node. The pool +// keeps the node spelling it was created with, the replica the CR name. +func TestFirstAvailablePoolMatchesTheReplicasCRNodeName(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + st := k8s.New(cli) + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + StoragePoolName: "pool", NodeName: "Node-1", ProviderKind: apiv1.StoragePoolKindLVMThin, + }); err != nil { + t.Fatalf("create the pool through the store: %v", err) + } + + for name, find := range map[string]func(context.Context, string) (string, error){ + "resource": (&ResourceReconciler{Client: cli, Store: st}).firstAvailablePool, + "auto-diskful": (&AutoDiskfulReconciler{Client: cli, Store: st}).firstAvailablePool, + } { + got, err := find(ctx, naming.Key("Node-1")) + if err != nil { + t.Fatalf("%s firstAvailablePool: %v", name, err) + } + + if got != "pool" { + t.Errorf("%s firstAvailablePool(node-1) = %q, want the pool created on Node-1", name, got) + } + } +} + +// Evacuating a node migrates every replica on it, the one stored under the +// node's CR name as well as one written before in the registered spelling. +// A definition without a group refuses the migration with an annotation, +// which shows the replica was reached. +func TestEvacuationReachesReplicasInEverySpellingOfTheNode(t *testing.T) { + t.Parallel() + + for _, spelling := range []string{"NODE-1", "node-1"} { + t.Run(spelling, func(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}, [2]string{"Pvc-Mix", spelling}) + ctx := context.Background() + + var node blockstoriov1alpha1.Node + if err := cli.Get(ctx, types.NamespacedName{Name: "node-1"}, &node); err != nil { + t.Fatalf("get the node: %v", err) + } + + node.Spec.Flags = append(node.Spec.Flags, apiv1.NodeFlagEvicted) + if err := cli.Update(ctx, &node); err != nil { + t.Fatalf("evict the node: %v", err) + } + + r := &NodeReconciler{Client: cli, Store: k8s.New(cli)} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-1"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + var rd blockstoriov1alpha1.ResourceDefinition + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc-mix"}, &rd); err != nil { + t.Fatalf("get the definition: %v", err) + } + + if rd.Annotations[AnnotationMigrationBlocked] == "" { + t.Errorf("the replica created as %s on Node-1 was not migrated", spelling) + } + }) + } +} + +// A replica written before the store stored CR names keeps Node-1 in spec; +// a node event for node-1 still reaches it. +func TestProjectionReachesAReplicaStoredInTheRegisteredSpelling(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}) + ctx := context.Background() + + if err := cli.Create(ctx, &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-1"}, + }); err != nil { + t.Fatalf("seed the older replica: %v", err) + } + + var node blockstoriov1alpha1.Node + if err := cli.Get(ctx, types.NamespacedName{Name: "node-1"}, &node); err != nil { + t.Fatalf("get the node: %v", err) + } + + reqs := (&ResourceStateProjectionReconciler{Client: cli}).enqueueResourcesForNode(ctx, &node) + if len(reqs) != 1 || reqs[0].Name != "pvc-mix.node-1" { + t.Errorf("node event enqueued %v, want pvc-mix.node-1", reqs) + } +} + +// naming.Same tells two spellings of one node from two nodes, the way the +// store addresses them. A case variant of a name outside RFC 1123 hashes to +// another CR name, so My_Node and my_node are two nodes. +func TestSameNodeFoldsCase(t *testing.T) { + t.Parallel() + + for _, pair := range [][2]string{{"Node-1", "node-1"}, {"Node-1", naming.Key("Node-1")}, {"my_node", naming.Key("my_node")}} { + if !naming.Same(pair[0], pair[1]) { + t.Errorf("%s and %s read as two nodes", pair[0], pair[1]) + } + } + + for _, pair := range [][2]string{{"node-1", "node-2"}, {"My_Node", "my_node"}} { + if naming.Same(pair[0], pair[1]) { + t.Errorf("%s and %s read as one node", pair[0], pair[1]) + } + } +} diff --git a/pkg/dispatcher/dispatcher.go b/pkg/dispatcher/dispatcher.go index 2462de18..4193eebc 100644 --- a/pkg/dispatcher/dispatcher.go +++ b/pkg/dispatcher/dispatcher.go @@ -36,9 +36,9 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/passphrase" intent "github.com/cozystack/blockstor/pkg/satellite/intent" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // drbdAddrAny is the placeholder address we put into the .res file @@ -86,15 +86,20 @@ func BuildDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alp // unresolved replicas entirely and emit a real id (incl. 0) only // when one is persisted — mirroring the nodeIDOf()==-1 skip // contract end-to-end. + // + // idOf is keyed by node (naming.Key), so a peer row and the target that + // spell one node differently are one node; spelled keeps the spelling a + // peer's row stores, which is the name its DRBD connection carries. idOf := map[string]*int32{} + spelled := map[string]string{} if id := nodeIDOf(target); id >= 0 { v := id - idOf[target.Spec.NodeName] = &v + idOf[naming.Key(target.Spec.NodeName)] = &v } for i := range peers { - if peers[i].Spec.NodeName == target.Spec.NodeName { + if naming.Same(peers[i].Spec.NodeName, target.Spec.NodeName) { continue } @@ -112,16 +117,15 @@ func BuildDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alp if id := nodeIDOf(&peers[i]); id >= 0 { v := id - idOf[peers[i].Spec.NodeName] = &v + idOf[naming.Key(peers[i].Spec.NodeName)] = &v + spelled[naming.Key(peers[i].Spec.NodeName)] = peers[i].Spec.NodeName } } - dropped := make([]string, 0, len(idOf)) + dropped := make([]string, 0, len(spelled)) - for name := range idOf { - if name != target.Spec.NodeName { - dropped = append(dropped, name) - } + for _, name := range spelled { + dropped = append(dropped, name) } slices.Sort(dropped) @@ -250,7 +254,11 @@ func assembleDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1 } return &intent.DesiredResource{ - Name: target.Spec.ResourceDefinitionName, + // The definition's CR name, whatever spelling an older row keeps + // in spec: the satellite names the DRBD resource, the volumes and + // the snapshots by it, and the snapshot and delete paths ask for + // it by the same name. + Name: naming.Key(target.Spec.ResourceDefinitionName), NodeName: target.Spec.NodeName, Flags: target.Spec.Flags, Props: wireProps, @@ -305,7 +313,7 @@ func buildDesiredPeers(dropped []string, peers []blockstoriov1alpha1.Resource) [ byName := make(map[string]*blockstoriov1alpha1.Resource, len(peers)) for i := range peers { - byName[peers[i].Spec.NodeName] = &peers[i] + byName[naming.Key(peers[i].Spec.NodeName)] = &peers[i] } out := make([]intent.DesiredPeer, 0, len(dropped)) @@ -313,7 +321,7 @@ func buildDesiredPeers(dropped []string, peers []blockstoriov1alpha1.Resource) [ for _, name := range dropped { entry := intent.DesiredPeer{Name: name} - if peerCR, ok := byName[name]; ok { + if peerCR, ok := byName[naming.Key(name)]; ok { // Bug 342 C3: store the pointer (nil = unallocated, never // a spurious 0) so a peer legitimately on node-id 0 is // distinguishable from an unallocated peer downstream. @@ -389,7 +397,7 @@ func nodeIDOf(r *blockstoriov1alpha1.Resource) int32 { // wedge on luks-autoplace. Returns (id, true) when present, (0, false) // when absent. func idLookup(idOf map[string]*int32, name string) (int32, bool) { - p, ok := idOf[name] + p, ok := idOf[naming.Key(name)] if !ok || p == nil { return 0, false } @@ -424,7 +432,7 @@ func readDRBDPort(target *blockstoriov1alpha1.Resource, _ []blockstoriov1alpha1. return int(*target.Status.DRBDPort) } - return derivePort(target.Spec.ResourceDefinitionName) + return derivePort(naming.Key(target.Spec.ResourceDefinitionName)) } // readDRBDMinor returns the BASE minor (volume 0) for the resource, @@ -449,7 +457,7 @@ func readDRBDMinor(target *blockstoriov1alpha1.Resource, rd *blockstoriov1alpha1 return int(*target.Status.DRBDMinor) } - return deriveMinor(target.Spec.ResourceDefinitionName) + return deriveMinor(naming.Key(target.Spec.ResourceDefinitionName)) } // peerPortOf reads the persisted DRBDPort off a peer Resource. The @@ -474,7 +482,7 @@ func peerPortOf(r *blockstoriov1alpha1.Resource, fallback int) int { func addPeerEntries(drbdOpts map[string]string, dropped []string, peers []blockstoriov1alpha1.Resource, nodes []blockstoriov1alpha1.Node, pools []blockstoriov1alpha1.StoragePool, fallbackPort int, idOf map[string]*int32) { peerByName := make(map[string]*blockstoriov1alpha1.Resource, len(peers)) for i := range peers { - peerByName[peers[i].Spec.NodeName] = &peers[i] + peerByName[naming.Key(peers[i].Spec.NodeName)] = &peers[i] } for _, peer := range dropped { @@ -482,7 +490,7 @@ func addPeerEntries(drbdOpts map[string]string, dropped []string, peers []blocks peerPool := "" - if p, ok := peerByName[peer]; ok { + if p, ok := peerByName[naming.Key(peer)]; ok { peerPort = peerPortOf(p, fallbackPort) peerPool = targetPoolName(p) } @@ -497,7 +505,7 @@ func addPeerEntries(drbdOpts map[string]string, dropped []string, peers []blocks // Surface the peer's DISKLESS flag so the satellite's .res // renderer can emit `disk none;` instead of the diskful // placeholder for diskless peers. - if p, ok := peerByName[peer]; ok && slices.Contains(p.Spec.Flags, "DISKLESS") { + if p, ok := peerByName[naming.Key(peer)]; ok && slices.Contains(p.Spec.Flags, "DISKLESS") { drbdOpts["peer."+peer+".diskless"] = boolPropTrue } } @@ -567,11 +575,11 @@ func peerAddressWithPrefNic(nodeName, poolName string, nodes []blockstoriov1alph func lookupPrefNic(nodeName, poolName string, nodes []blockstoriov1alpha1.Node, pools []blockstoriov1alpha1.StoragePool) string { if poolName != "" { for i := range pools { - if pools[i].Spec.NodeName != nodeName { + if !naming.Same(pools[i].Spec.NodeName, nodeName) { continue } - if pools[i].Spec.PoolName != poolName { + if !naming.Same(pools[i].Spec.PoolName, poolName) { continue } @@ -584,7 +592,7 @@ func lookupPrefNic(nodeName, poolName string, nodes []blockstoriov1alpha1.Node, } for i := range nodes { - if k8s.OriginalName(&nodes[i].ObjectMeta) != nodeName { + if nodes[i].Name != naming.Key(nodeName) { continue } @@ -600,7 +608,7 @@ func lookupPrefNic(nodeName, poolName string, nodes []blockstoriov1alpha1.Node, // source rather than 500-ing. func lookupNetInterfaceAddress(nodeName, ifaceName string, nodes []blockstoriov1alpha1.Node) string { for i := range nodes { - if k8s.OriginalName(&nodes[i].ObjectMeta) != nodeName { + if nodes[i].Name != naming.Key(nodeName) { continue } @@ -669,7 +677,7 @@ func anyDiskfulPeerHasData(peers []blockstoriov1alpha1.Resource) bool { continue } - rdName := peers[i].Spec.ResourceDefinitionName + rdName := naming.Key(peers[i].Spec.ResourceDefinitionName) for j := range peers[i].Status.Volumes { vol := &peers[i].Status.Volumes[j] @@ -882,7 +890,7 @@ func buildVolumes(rd *blockstoriov1alpha1.ResourceDefinition, target *blockstori srcSnapshot := "" if rd.Spec.Props != nil { - srcSnapshot = rd.Spec.Props[restoreFromKey] + srcSnapshot = restoreSourceOnDisk(rd.Spec.Props[restoreFromKey]) } out := make([]*intent.DesiredVolume, 0, len(rd.Spec.VolumeDefinitions)) @@ -924,6 +932,21 @@ func buildVolumes(rd *blockstoriov1alpha1.ResourceDefinition, target *blockstori return out } +// restoreSourceOnDisk names a restore's source definition the way its +// satellite named it on disk: by its CR name, as buildDesired does. +// The prop carries the source as the REST request spelled it, and a +// source registered as Pvc-Src was laid down as pvc-src. Anything that is +// not :, such as the cross-cluster form, is left for the +// satellite to refuse as it is. +func restoreSourceOnDisk(value string) string { + parts := strings.Split(value, ":") + if len(parts) != 2 || parts[0] == "" { + return value + } + + return naming.Key(parts[0]) + ":" + parts[1] +} + // StorPoolNameDrbdMetaKey is the upstream-LINSTOR prop key that // routes DRBD activity-log + bitmap + generation-id state to a // separate storage pool. Set by operators with e.g. @@ -1020,11 +1043,11 @@ const defaultNICName = "default" // legacy "first non-empty" behaviour so single-NIC clusters keep // working unchanged. (Bug 48) // -// Match is on the original LINSTOR name (annotation when slugified, -// else metadata.Name) so non-RFC1123 LINSTOR names still resolve. +// Match is by naming.Key, so a replica that stores the node's CR name and +// one written before in the registered spelling both resolve. func lookupEndpoint(nodeName string, nodes []blockstoriov1alpha1.Node) string { for i := range nodes { - if k8s.OriginalName(&nodes[i].ObjectMeta) != nodeName { + if nodes[i].Name != naming.Key(nodeName) { continue } diff --git a/pkg/dispatcher/node_spelling_test.go b/pkg/dispatcher/node_spelling_test.go new file mode 100644 index 00000000..5172c471 --- /dev/null +++ b/pkg/dispatcher/node_spelling_test.go @@ -0,0 +1,140 @@ +// 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 dispatcher_test + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/dispatcher" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +const peerEndpoint = "192.0.2.2" + +func registeredNode(registered string, spec blockstoriov1alpha1.NodeSpec) blockstoriov1alpha1.Node { + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(registered)}, Spec: spec} + k8s.SetOriginalName(&node.ObjectMeta, registered) + + return node +} + +func replicaOn(node string, id int32) blockstoriov1alpha1.Resource { + return blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-1." + naming.Key(node)}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: "pvc-1", + NodeName: node, + StoragePool: "data", + }, + Status: blockstoriov1alpha1.ResourceStatus{DRBDNodeID: &id}, + } +} + +// A peer resolves to its node's address whichever spelling the replica +// stores: the node's CR name, which the store writes, or the registered +// spelling a replica written before carries. +func TestPeerAddressResolvesUnderEverySpellingOfItsNode(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + registered string + stored string + }{ + {name: "lowercase", registered: "node-2", stored: "node-2"}, + {name: "mixed case, CR name stored", registered: "Node-2", stored: naming.Key("Node-2")}, + {name: "mixed case, registered spelling stored", registered: "Node-2", stored: "Node-2"}, + {name: "outside RFC 1123, CR name stored", registered: "my_node", stored: naming.Key("my_node")}, + {name: "outside RFC 1123, registered spelling stored", registered: "my_node", stored: "my_node"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + target := replicaOn("node-1", 0) + peer := replicaOn(tc.stored, 1) + nodes := []blockstoriov1alpha1.Node{ + registeredNode("node-1", blockstoriov1alpha1.NodeSpec{SatelliteEndpoint: "192.0.2.1"}), + registeredNode(tc.registered, blockstoriov1alpha1.NodeSpec{SatelliteEndpoint: peerEndpoint}), + } + + got := dispatcher.BuildDesired(&target, []blockstoriov1alpha1.Resource{peer}, nodes, nil, nil, nil) + + key := "peer." + tc.stored + ".address" + if addr := got.DrbdOptions[key]; addr != peerEndpoint { + t.Errorf("%s = %q, want %s", key, addr, peerEndpoint) + } + }) + } +} + +// A case variant of a name outside RFC 1123 hashes to another CR name, so +// My_Node is another node than my_node, the way the store keeps them: a +// replica stored as My_Node is not given my_node's address. +func TestACaseVariantOutsideRFC1123IsAnotherNode(t *testing.T) { + t.Parallel() + + target := replicaOn("node-1", 0) + peer := replicaOn("My_Node", 1) + nodes := []blockstoriov1alpha1.Node{ + registeredNode("node-1", blockstoriov1alpha1.NodeSpec{SatelliteEndpoint: "192.0.2.1"}), + registeredNode("my_node", blockstoriov1alpha1.NodeSpec{SatelliteEndpoint: peerEndpoint}), + } + + got := dispatcher.BuildDesired(&target, []blockstoriov1alpha1.Resource{peer}, nodes, nil, nil, nil) + if addr := got.DrbdOptions["peer.My_Node.address"]; addr == peerEndpoint { + t.Errorf("peer.My_Node.address = %s, the address of my_node, another node", addr) + } +} + +// A PrefNic on the peer's pool, and a NetInterface it names on the node, +// are found when the replica stores the node's CR name and the pool its +// registered spelling. +func TestPeerPrefNicResolvesUnderTheCRNameOfItsNode(t *testing.T) { + t.Parallel() + + const replication = "192.0.2.22" + + target := replicaOn("node-1", 0) + peer := replicaOn(naming.Key("Node-2"), 1) + nodes := []blockstoriov1alpha1.Node{ + registeredNode("node-1", blockstoriov1alpha1.NodeSpec{SatelliteEndpoint: "192.0.2.1"}), + registeredNode("Node-2", blockstoriov1alpha1.NodeSpec{ + SatelliteEndpoint: peerEndpoint, + NetInterfaces: []blockstoriov1alpha1.NodeNetInterface{{Name: "repl", Address: replication}}, + }), + } + pools := []blockstoriov1alpha1.StoragePool{{ + ObjectMeta: metav1.ObjectMeta{Name: "data.node-2"}, + Spec: blockstoriov1alpha1.StoragePoolSpec{ + NodeName: "Node-2", + PoolName: "data", + Props: map[string]string{"PrefNic": "repl"}, + }, + }} + + got := dispatcher.BuildDesired(&target, []blockstoriov1alpha1.Resource{peer}, nodes, pools, nil, nil) + + if addr := got.DrbdOptions["peer.node-2.address"]; addr != replication { + t.Errorf("peer.node-2.address = %q, want the PrefNic address %s", addr, replication) + } +} diff --git a/pkg/dispatcher/old_spelling_name_test.go b/pkg/dispatcher/old_spelling_name_test.go new file mode 100644 index 00000000..19bb89eb --- /dev/null +++ b/pkg/dispatcher/old_spelling_name_test.go @@ -0,0 +1,112 @@ +// 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 dispatcher_test + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/dispatcher" + "github.com/cozystack/blockstor/pkg/drbd" +) + +func spelledReplica(rd, node string) blockstoriov1alpha1.Resource { + return blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix." + node}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: rd, + NodeName: node, + StoragePool: "data", + }, + } +} + +func spelledRD(props map[string]string) *blockstoriov1alpha1.ResourceDefinition { + return &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + Props: props, + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0, SizeKib: 4096}}, + }, + } +} + +// A replica written before the store kept CR names in spec names its +// definition Pvc-Mix. Its satellite names the resource, and derives the +// port and minor it falls back to, from the CR name, the way every other +// replica of the definition does. +func TestAnOldMixedCaseReplicaIsNamedByItsCRName(t *testing.T) { + t.Parallel() + + rd := spelledRD(nil) + old := spelledReplica("Pvc-Mix", "n1") + current := spelledReplica("pvc-mix", "n1") + + gotOld := dispatcher.BuildDesired(&old, nil, nil, nil, rd, nil) + gotCurrent := dispatcher.BuildDesired(¤t, nil, nil, nil, rd, nil) + + if gotOld.GetName() != "pvc-mix" { + t.Errorf("resource name %q, want the CR name pvc-mix", gotOld.GetName()) + } + + for _, key := range []string{"port", "minor"} { + if a, b := gotOld.GetDrbdOptions()[key], gotCurrent.GetDrbdOptions()[key]; a != b { + t.Errorf("%s %s under Pvc-Mix, %s under pvc-mix; want one", key, a, b) + } + } +} + +// A peer stored as Pvc-Mix carrying the day0 generation its satellite +// minted under the CR name is a fresh sibling, not a peer with data. +func TestAnOldMixedCasePeersDay0GenerationIsRecognised(t *testing.T) { + t.Parallel() + + peer := spelledReplica("Pvc-Mix", "n2") + peer.Status.Volumes = []blockstoriov1alpha1.ResourceVolumeStatus{{ + VolumeNumber: 0, + DiskState: string(drbd.DiskStateUpToDate), + CurrentGI: drbd.Day0GIFor("pvc-mix", 0), + }} + + target := spelledReplica("pvc-mix", "n1") + + if got := dispatcher.BuildDesired(&target, []blockstoriov1alpha1.Resource{peer}, nil, nil, spelledRD(nil), nil); got.GetPeerHasData() { + t.Error("a day0 sibling stored as Pvc-Mix reads as a peer with data") + } +} + +// A restore names its source the way the source's satellite named it on +// disk, by its CR name; the cross-cluster form is left as it is. +func TestARestoreSourceIsNamedByItsCRName(t *testing.T) { + t.Parallel() + + for source, want := range map[string]string{ + "Pvc-Src:snap-1": "pvc-src:snap-1", + "remote:Pvc-Src:snap-1": "remote:Pvc-Src:snap-1", + } { + target := spelledReplica("pvc-mix", "n1") + got := dispatcher.BuildDesired(&target, nil, nil, nil, spelledRD(map[string]string{"BlockstorRestoreFromSnapshot": source}), nil) + + if vols := got.GetVolumes(); len(vols) != 1 || vols[0].GetSourceSnapshot() != want { + t.Errorf("source %q became %v; want %q", source, vols, want) + } + } +} diff --git a/pkg/drbd/conffile.go b/pkg/drbd/conffile.go index c5685b5f..822ca1cf 100644 --- a/pkg/drbd/conffile.go +++ b/pkg/drbd/conffile.go @@ -28,6 +28,8 @@ import ( "strings" "github.com/cockroachdb/errors" + + "github.com/cozystack/blockstor/pkg/naming" ) // Resource is the top-level drbd-9 resource definition. It maps 1:1 to @@ -630,12 +632,15 @@ func writeOneConnection(b *strings.Builder, hostA, hostB *Host, conns []Resource // regardless of which order the operator stored on the // ResourceConnection — keeps the rendered `host a address …;` // line consistent with the on-block address we'd otherwise emit. +// +// A connection and a host may spell one node differently, so they are +// matched as the store addresses nodes. func lookupPaths(conns []ResourceConnection, a, b string) []ResourcePath { for i := range conns { switch { - case conns[i].NodeA == a && conns[i].NodeB == b: + case naming.Same(conns[i].NodeA, a) && naming.Same(conns[i].NodeB, b): return conns[i].Paths - case conns[i].NodeA == b && conns[i].NodeB == a: + case naming.Same(conns[i].NodeA, b) && naming.Same(conns[i].NodeB, a): return swapPathSides(conns[i].Paths) } } diff --git a/pkg/drbd/lookup_paths_spelling_test.go b/pkg/drbd/lookup_paths_spelling_test.go new file mode 100644 index 00000000..bef5aa9e --- /dev/null +++ b/pkg/drbd/lookup_paths_spelling_test.go @@ -0,0 +1,40 @@ +// 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 drbd + +import "testing" + +// A connection stored as Node-1/beta renders between the hosts node-1 and +// beta, in either order. +func TestLookupPathsMatchesAConnectionInAnotherSpelling(t *testing.T) { + t.Parallel() + + conns := []ResourceConnection{{ + NodeA: "Node-1", NodeB: "beta", + Paths: []ResourcePath{{Name: "p1", AddressA: "10.0.0.1", AddressB: "10.0.0.2"}}, + }} + + if got := lookupPaths(conns, "node-1", "beta"); len(got) != 1 || got[0].AddressA != "10.0.0.1" { + t.Errorf("node-1, beta: %+v", got) + } + + if got := lookupPaths(conns, "beta", "node-1"); len(got) != 1 || got[0].AddressA != "10.0.0.2" { + t.Errorf("beta, node-1: %+v", got) + } +} diff --git a/pkg/effectiveprops/effective.go b/pkg/effectiveprops/effective.go index 6cc0952c..3f9092ae 100644 --- a/pkg/effectiveprops/effective.go +++ b/pkg/effectiveprops/effective.go @@ -43,7 +43,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" "github.com/cozystack/blockstor/pkg/drbd" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // Resolve walks the four scopes and returns the merged Props @@ -217,7 +217,7 @@ func scopeInputs(ctx context.Context, c client.Reader, rd *blockstoriov1alpha1.R // The RD keeps the group's name as the operator spelled it; the CR is // stored under the folded one. - getErr := c.Get(ctx, client.ObjectKey{Name: k8s.Name(rd.Spec.ResourceGroupName)}, &rgObj) + getErr := c.Get(ctx, client.ObjectKey{Name: naming.Key(rd.Spec.ResourceGroupName)}, &rgObj) switch { case getErr == nil: rgInfo = scopeInfo{ diff --git a/pkg/linstormigrate/convert.go b/pkg/linstormigrate/convert.go index 9db8bd61..5214f332 100644 --- a/pkg/linstormigrate/convert.go +++ b/pkg/linstormigrate/convert.go @@ -1911,8 +1911,8 @@ func (c *converter) buildResource(row *ResourceRow, rdDsp, nodeDsp, replicaName TypeMeta: typeMeta("Resource"), ObjectMeta: objectMeta(replicaName), Spec: crdv1alpha1.ResourceSpec{ - ResourceDefinitionName: rdDsp, - NodeName: nodeDsp, + ResourceDefinitionName: k8sstore.Name(rdDsp), + NodeName: k8sstore.Name(nodeDsp), Props: residual, DRBDOptions: typed, ExtraProps: extra, @@ -1924,6 +1924,9 @@ func (c *converter) buildResource(row *ResourceRow, rdDsp, nodeDsp, replicaName }, } + annotateParent(&resource.ObjectMeta, k8sstore.AnnotationLinstorRDName, rdDsp) + annotateParent(&resource.ObjectMeta, k8sstore.AnnotationLinstorNodeName, nodeDsp) + 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 != "" { @@ -1992,7 +1995,7 @@ func (c *converter) buildSnapshot(row *ResourceDefinitionRow, rdDsp, snapDsp, na TypeMeta: typeMeta("Snapshot"), ObjectMeta: objectMeta(name), Spec: crdv1alpha1.SnapshotSpec{ - ResourceDefinitionName: rdDsp, + ResourceDefinitionName: k8sstore.Name(rdDsp), SnapshotName: snapDsp, Props: c.props.SnapshotDefinition(row.ResourceName, row.SnapshotName), Nodes: nodes, @@ -2005,6 +2008,7 @@ func (c *converter) buildSnapshot(row *ResourceDefinitionRow, rdDsp, snapDsp, na // 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} + annotateParent(&snap.ObjectMeta, k8sstore.AnnotationLinstorRDName, rdDsp) if ts := c.snapshotCreatedAtFor(row.ResourceName, row.SnapshotName); ts > 0 { snap.Annotations[crdv1alpha1.AnnotationSnapshotAdoptedCreatedAt] = strconv.FormatInt(ts, 10) @@ -2205,6 +2209,20 @@ func objectMeta(original string) metav1.ObjectMeta { return meta } +// annotateParent keeps a parent's LINSTOR spelling beside the CR name the +// spec stores, the way the store does on Create, when the two differ. +func annotateParent(meta *metav1.ObjectMeta, key, display string) { + if display == k8sstore.Name(display) { + return + } + + if meta.Annotations == nil { + meta.Annotations = map[string]string{} + } + + meta.Annotations[key] = display +} + 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]) }) } diff --git a/pkg/linstormigrate/parent_names_test.go b/pkg/linstormigrate/parent_names_test.go new file mode 100644 index 00000000..92eab4cf --- /dev/null +++ b/pkg/linstormigrate/parent_names_test.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 ( + "testing" + + k8sstore "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// An adopted replica and snapshot store their parents the way the store +// writes them: the CR name in spec, the LINSTOR spelling in an annotation. +// The controller and the satellite Get the parent by the spec value, so a +// mixed-case spelling there would miss it. +func TestAdoptedChildrenStoreTheirParentsUnderCRNames(t *testing.T) { + t.Parallel() + + dump := &Dump{ + Nodes: []NodeRow{ + {NodeName: "NODE-MIX", NodeDspName: "Node-Mix", NodeType: 2, UUID: "n-a"}, + }, + ResourceDefinitions: []ResourceDefinitionRow{ + {ResourceName: "PVC-MIX", ResourceDspName: "Pvc-Mix", LayerStack: `["DRBD","STORAGE"]`, UUID: "rd-mix"}, + { + ResourceName: "PVC-MIX", ResourceDspName: "Pvc-Mix", SnapshotName: "SNAP", SnapshotDspName: "snap", + ResourceFlags: snapDfnFlagSuccessful, UUID: "sd-mix", + }, + }, + VolumeDefinitions: []VolumeDefinitionRow{ + {ResourceName: "PVC-MIX", VlmNr: 0, VlmSize: 1048576, UUID: "vd-mix"}, + }, + Resources: []ResourceRow{ + {NodeName: "NODE-MIX", ResourceName: "PVC-MIX", UUID: "r-mix"}, + {NodeName: "NODE-MIX", ResourceName: "PVC-MIX", SnapshotName: "SNAP", UUID: "s-mix"}, + }, + } + + res, err := Convert(dump) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(res.Resources) != 1 || len(res.Snapshots) != 1 { + t.Fatalf("resources %d, snapshots %d, want 1 each; warnings %v", len(res.Resources), len(res.Snapshots), res.Warnings) + } + + r := res.Resources[0] + if r.Spec.ResourceDefinitionName != "pvc-mix" || r.Spec.NodeName != "node-mix" { + t.Errorf("replica spec stores %q on %q, want the CR names pvc-mix on node-mix", + r.Spec.ResourceDefinitionName, r.Spec.NodeName) + } + + if got := r.Annotations[k8sstore.AnnotationLinstorRDName]; got != "Pvc-Mix" { + t.Errorf("replica keeps definition spelling %q, want Pvc-Mix", got) + } + + if got := r.Annotations[k8sstore.AnnotationLinstorNodeName]; got != "Node-Mix" { + t.Errorf("replica keeps node spelling %q, want Node-Mix", got) + } + + s := res.Snapshots[0] + if s.Spec.ResourceDefinitionName != "pvc-mix" { + t.Errorf("snapshot spec stores %q, want the CR name pvc-mix", s.Spec.ResourceDefinitionName) + } + + if got := s.Annotations[k8sstore.AnnotationLinstorRDName]; got != "Pvc-Mix" { + t.Errorf("snapshot keeps definition spelling %q, want Pvc-Mix", got) + } +} + +// A lowercase parent needs no annotation: the spec value is its spelling. +func TestALowercaseParentGetsNoSpellingAnnotation(t *testing.T) { + t.Parallel() + + dump := &Dump{ + Nodes: []NodeRow{ + {NodeName: "NODE-A", NodeDspName: "node-a", NodeType: 2, UUID: "n-a"}, + }, + ResourceDefinitions: []ResourceDefinitionRow{ + {ResourceName: "PVC-A", ResourceDspName: "pvc-a", LayerStack: `["DRBD","STORAGE"]`, UUID: "rd-a"}, + }, + VolumeDefinitions: []VolumeDefinitionRow{ + {ResourceName: "PVC-A", VlmNr: 0, VlmSize: 1048576, UUID: "vd-a"}, + }, + Resources: []ResourceRow{ + {NodeName: "NODE-A", ResourceName: "PVC-A", UUID: "r-a"}, + }, + } + + res, err := Convert(dump) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(res.Resources) != 1 { + t.Fatalf("resources %d, want 1; warnings %v", len(res.Resources), res.Warnings) + } + + for _, key := range []string{k8sstore.AnnotationLinstorRDName, k8sstore.AnnotationLinstorNodeName} { + if v, ok := res.Resources[0].Annotations[key]; ok { + t.Errorf("annotation %s = %q on a lowercase parent", key, v) + } + } +} diff --git a/pkg/naming/guard_test.go b/pkg/naming/guard_test.go new file mode 100644 index 00000000..fd9c2e6f --- /dev/null +++ b/pkg/naming/guard_test.go @@ -0,0 +1,1553 @@ +// 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 naming_test + +import ( + "go/ast" + "go/constant" + "go/token" + "go/types" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" + + "golang.org/x/tools/go/packages" +) + +// The guard follows values that hold a LINSTOR object name from where they +// enter to where two spellings could meet, and fails where one gets there +// without naming.Key or naming.Same on the way. It works on types and on +// where a value comes from, not on what a variable is called. +// +// A name enters (a source) as: a name field of a wire, CRD, REST, CLI, +// store, satellite intent or DRBD configuration type (sourceFields, and Name +// on the types in namedTypes); a +// REST path parameter or query list that names objects (pathKeys, +// multiValueQuery); a CLI flag that names one (flagKeys); the node a +// satellite controller is configured with; and anything passed to a store +// method's name parameter (storeParams), whatever it is called. +// +// It travels through: assignment and declaration; range over a slice, over +// a map's values, and over the keys of a map (a variable or a field) that +// something keys by a raw name, maps.Keys of one included; indexing, slicing, dereference and type assertion; +// concatenation, fmt.Sprint*, strings.Join, path.Join and the strings +// trimming, cutting, splitting and case helpers; append, and slice, array and map +// literals; a write into a slice or map element, which taints the whole +// collection; string fields of this module's structs other than the +// source types, through composite literals and assignments, per field +// rather than per value; string conversions; calls, into the parameters of +// a declared function, a closure bound to a local variable or called in +// place, the function a slices.*Func call is given, and every method in +// the tree that implements a method of one of this module's interfaces; +// and back out of a call through its returns, with a parameter the callee +// returns standing for whatever that one call passes there, so a helper +// that returns what it is given returns a name only to callers that give +// it one. It runs to a fixpoint. naming.Key's result and a CR's +// metadata.name are keys, not raw names. Only values that can hold a +// string are tainted; a struct is followed field by field. +// +// It reports (a sink): == and != between two names, one of them raw, the +// other raw or a key (naming.Key's result, a helper that returns one, a CR's +// metadata.name or GetName()); a switch on a name with a case of another; strings.HasPrefix, HasSuffix, +// Contains, CutPrefix and CutSuffix, and slices.Contains and Index, between +// two names; strings.EqualFold, ToLower and ToUpper on a raw name; a map +// indexed, a map literal keyed, or a map entry deleted, by a raw name or a +// key built from one; +// and a CR addressed by a raw name. One field read off two elements of the +// same collection, by index or as the two arguments of the comparator a +// slices sort is given, is one source and not a pair. +// +// It does not see: channels, package-level variables, pointers aliased +// across calls, reflection, method values, function values other than the +// closures above, interfaces declared outside this module (error, Stringer +// and the like), calls into code outside this module other than the ones +// listed, generic code whose type parameter hides the string, and a name +// that leaves the process and comes back (a file, a kernel listing, a CR +// read by its key). A parameter is raw for every caller once one caller +// passes a raw name, and a field for every value once one value holds one, +// which errs towards reporting. + +const module = "github.com/cozystack/blockstor" + +// sourceFields are the fields that hold an object's name or a reference to +// another object, on the types of the packages in sourcePackages. +func sourceFields() map[string]bool { + return map[string]bool{ + "NodeName": true, "ResourceName": true, "ResourceDefinitionName": true, "RscName": true, + "ResourceGroupName": true, "StoragePoolName": true, "PoolName": true, "Nodes": true, + "NodeNameList": true, "SnapshotName": true, "PeerName": true, "NodeA": true, "NodeB": true, + "Resources": true, + } +} + +// storeParams are the parameters of the store's methods that take an +// object name. Whatever a caller passes there is a name, whatever it is +// called and wherever it came from. +func storeParams() map[string]bool { + return map[string]bool{"name": true, "node": true, "nodeName": true, "pool": true, "rdName": true, "snapName": true} +} + +// pathKeys are the REST path parameters that carry an object name. +func pathKeys() map[string]bool { + return map[string]bool{ + "node": true, "nodeA": true, "nodeB": true, "src": true, "dst": true, "rd": true, "rg": true, + "pool": true, "snap": true, "target": true, + } +} + +// flagKeys are the CLI flags that carry an object name. +func flagKeys() map[string]bool { + return map[string]bool{ + "from-resource": true, "from-snapshot": true, "migrate-from": true, "pool-name": true, + "resource-group": true, "storage-pool": true, "storage-pools": true, "to-resource": true, + } +} + +// sourcePackages hold the types whose name fields are sources: the wire API, +// the CRDs' specs, the REST surface, the CLI's parsed arguments, the +// store's own types, the intent the dispatcher hands a satellite and the +// DRBD configuration a satellite renders from it. +func sourcePackages() map[string]bool { + return map[string]bool{ + module + "/pkg/api/v1": true, module + "/api/v1alpha1": true, module + "/pkg/rest": true, + module + "/internal/cli": true, module + "/pkg/store": true, module + "/pkg/satellite/intent": true, + module + "/pkg/drbd": true, + } +} + +// namedTypes are the types whose own Name is an object name as some client +// spelled it: the wire types, in the spelling they were registered under, +// and an intent's peer, under the spelling its row stores. A CRD's own Name +// is its CR name, a key, and so is not a source. +func namedTypes() map[string]bool { + out := map[string]bool{module + "/pkg/satellite/intent.DesiredPeer": true} + + for _, t := range []string{ + "Node", "ResourceDefinition", "ResourceGroup", "Resource", "ResourceWithVolumes", "Snapshot", + "StoragePool", "StoragePoolDefinition", + } { + out[module+"/pkg/api/v1."+t] = true + } + + return out +} + +// fn is a body the guard walks: a declared function or method, or a +// function literal. +type fn struct { + typ *ast.FuncType + body *ast.BlockStmt + info *types.Info +} + +type guard struct { + pkgs []*packages.Package + tainted map[types.Object]labels + reached map[types.Object]bool + // mapKeys are the maps, variables or fields, some write keys by a raw + // name; ranging over their keys yields names. + mapKeys map[types.Object]bool + keyed map[types.Object]bool + // returns and fns are keyed by *types.Func or *ast.FuncLit. + returns map[any]labels + // keyReturns are the functions that return a key. + keyReturns map[any]bool + fns map[any]*fn + // litOf is the literal a local variable is bound to. + litOf map[types.Object]*ast.FuncLit + // methods are the concrete methods with a body, by name, for calls + // through an interface. + methods map[string][]*types.Func + // siblings pairs the two parameters of a comparator a sort is given: + // two elements of one collection. + siblings map[types.Object]types.Object +} + +func newGuard(t *testing.T, dir string, patterns ...string) *guard { + t.Helper() + + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps, + Dir: dir, + } + + pkgs, err := packages.Load(cfg, patterns...) + if err != nil { + t.Fatalf("load %v: %v", patterns, err) + } + + for _, pkg := range pkgs { + for _, e := range pkg.Errors { + t.Fatalf("load %s: %v", pkg.PkgPath, e) + } + } + + g := &guard{ + pkgs: pkgs, tainted: map[types.Object]labels{}, reached: map[types.Object]bool{}, mapKeys: map[types.Object]bool{}, keyed: map[types.Object]bool{}, + returns: map[any]labels{}, keyReturns: map[any]bool{}, fns: map[any]*fn{}, litOf: map[types.Object]*ast.FuncLit{}, + methods: map[string][]*types.Func{}, siblings: map[types.Object]types.Object{}, + } + + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + g.collect(pkg.TypesInfo, file) + } + } + + g.propagate() + + return g +} + +// collect records the bodies in file, and which local variables are bound +// to a function literal. +func (g *guard) collect(info *types.Info, file *ast.File) { + for _, d := range file.Decls { + decl, ok := d.(*ast.FuncDecl) + if !ok || decl.Body == nil { + continue + } + + obj, ok := info.Defs[decl.Name].(*types.Func) + if !ok { + continue + } + + g.fns[obj] = &fn{typ: decl.Type, body: decl.Body, info: info} + + if decl.Recv != nil { + g.methods[obj.Name()] = append(g.methods[obj.Name()], obj) + } + } + + bind := func(lhs []ast.Expr, rhs []ast.Expr) { + if len(lhs) != len(rhs) { + return + } + + for i := range rhs { + if lit, ok := ast.Unparen(rhs[i]).(*ast.FuncLit); ok { + if obj := identObj(info, lhs[i]); obj != nil { + g.litOf[obj] = lit + } + } + } + } + + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.CallExpr: + g.pairComparator(info, x) + case *ast.FuncLit: + g.fns[x] = &fn{typ: x.Type, body: x.Body, info: info} + case *ast.AssignStmt: + bind(x.Lhs, x.Rhs) + case *ast.ValueSpec: + lhs := make([]ast.Expr, len(x.Names)) + for i, id := range x.Names { + lhs[i] = id + } + + bind(lhs, x.Values) + } + + return true + }) +} + +// pairComparator records the parameters of a comparator a sort of one +// collection is given as siblings. +func (g *guard) pairComparator(info *types.Info, call *ast.CallExpr) { + switch funcName(calleeOf(info, call)) { + case "slices.SortFunc", "slices.SortStableFunc", "slices.IsSortedFunc", "slices.MinFunc", "slices.MaxFunc", + "slices.CompactFunc": + default: + return + } + + lit, ok := ast.Unparen(call.Args[len(call.Args)-1]).(*ast.FuncLit) + if !ok { + return + } + + var params []types.Object + + for _, field := range lit.Type.Params.List { + for _, id := range field.Names { + params = append(params, info.Defs[id]) + } + } + + if len(params) == 2 && params[0] != nil && params[1] != nil { + g.siblings[params[0]] = params[1] + g.siblings[params[1]] = params[0] + } +} + +func isString(info *types.Info, e ast.Expr) bool { + t := info.TypeOf(e) + if t == nil { + return false + } + + b, ok := t.Underlying().(*types.Basic) + + return ok && b.Info()&types.IsString != 0 +} + +func isConstant(info *types.Info, e ast.Expr) bool { + if id, ok := e.(*ast.Ident); ok && id.Name == "nil" { + return true + } + + tv, ok := info.Types[e] + + return ok && tv.Value != nil +} + +// calleeOf is the function a call statically names, if any: for a call +// through an interface, the interface's method. +func calleeOf(info *types.Info, call *ast.CallExpr) *types.Func { + var f *types.Func + + switch fun := ast.Unparen(call.Fun).(type) { + case *ast.Ident: + f, _ = info.Uses[fun].(*types.Func) + case *ast.SelectorExpr: + if sel, ok := info.Selections[fun]; ok { + f, _ = sel.Obj().(*types.Func) + } else { + f, _ = info.Uses[fun.Sel].(*types.Func) + } + case *ast.IndexExpr: + if id, ok := fun.X.(*ast.Ident); ok { + f, _ = info.Uses[id].(*types.Func) + } + } + + if f == nil { + return nil + } + + return f.Origin() +} + +func funcName(f *types.Func) string { + if f == nil || f.Pkg() == nil { + return "" + } + + if recv := f.Type().(*types.Signature).Recv(); recv != nil { + t := types.Unalias(recv.Type()) + if p, ok := t.(*types.Pointer); ok { + t = types.Unalias(p.Elem()) + } + + if n, ok := t.(*types.Named); ok { + return f.Pkg().Path() + "." + n.Obj().Name() + "." + f.Name() + } + } + + return f.Pkg().Path() + "." + f.Name() +} + +// callees are the bodies a call may run: a literal called in place or +// through the variable bound to it, a declared function, or every method in +// the tree that implements the interface method called. +func (g *guard) callees(info *types.Info, call *ast.CallExpr) []any { + fun := ast.Unparen(call.Fun) + if lit, ok := fun.(*ast.FuncLit); ok { + return []any{lit} + } + + if id, ok := fun.(*ast.Ident); ok { + if lit := g.litOf[info.Uses[id]]; lit != nil { + return []any{lit} + } + } + + f := calleeOf(info, call) + if f == nil { + return nil + } + + if _, ok := g.fns[f]; ok { + return []any{f} + } + + // Only the module's own interfaces: every error in the tree is not + // what one call of Error runs. + recv := f.Type().(*types.Signature).Recv() + if recv == nil || !types.IsInterface(recv.Type()) || f.Pkg() == nil || !strings.HasPrefix(f.Pkg().Path(), module) { + return nil + } + + iface, _ := recv.Type().Underlying().(*types.Interface) + if iface == nil { + return nil + } + + var out []any + + for _, m := range g.methods[f.Name()] { + rt := types.Unalias(m.Type().(*types.Signature).Recv().Type()) + if p, ok := rt.(*types.Pointer); ok { + rt = p.Elem() + } + + if types.Implements(rt, iface) || types.Implements(types.NewPointer(rt), iface) { + out = append(out, m) + } + } + + return out +} + +func (g *guard) params(key any) []types.Object { + f := g.fns[key] + if f == nil || f.typ.Params == nil { + return nil + } + + var out []types.Object + + for _, field := range f.typ.Params.List { + for _, id := range field.Names { + out = append(out, f.info.Defs[id]) + } + + if len(field.Names) == 0 { + out = append(out, nil) + } + } + + return out +} + +// isKeyCall reports whether e is a call to naming.Key. +func isKeyCall(info *types.Info, e ast.Expr) bool { + call, ok := ast.Unparen(e).(*ast.CallExpr) + + return ok && funcName(calleeOf(info, call)) == module+"/pkg/naming.Key" +} + +func stringLit(info *types.Info, e ast.Expr) (string, bool) { + tv, ok := info.Types[e] + if !ok || tv.Value == nil || tv.Value.Kind() != constant.String { + return "", false + } + + return constant.StringVal(tv.Value), true +} + +// isSourceCall reports whether a call returns a name a client sent: a path +// parameter or a query list that names objects. +func isSourceCall(info *types.Info, call *ast.CallExpr) bool { + switch funcName(calleeOf(info, call)) { + case "net/http.Request.PathValue": + key, ok := stringLit(info, call.Args[0]) + + return ok && pathKeys()[key] + case module + "/pkg/rest.multiValueQuery": + return true + } + + return false +} + +// isFlagSource reports whether e reads a CLI flag that names an object. +func isFlagSource(info *types.Info, e *ast.IndexExpr) bool { + sel, ok := ast.Unparen(e.X).(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Values" { + return false + } + + selection, ok := info.Selections[sel] + if !ok || selection.Obj().Pkg() == nil || selection.Obj().Pkg().Path() != module+"/internal/cli" { + return false + } + + key, ok := stringLit(info, e.Index) + + return ok && flagKeys()[key] +} + +// isKey reports whether e holds a key: naming.Key's result, a value only +// ever assigned one, or a CR's own name. +func (g *guard) isKey(info *types.Info, e ast.Expr) bool { + switch x := ast.Unparen(e).(type) { + case *ast.CallExpr: + if isKeyCall(info, x) || funcName(calleeOf(info, x)) == "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta.GetName" { + return true + } + + // A helper that returns naming.Key's result returns a key, unless + // it returns a raw name too. + callees := g.callees(info, x) + for _, c := range callees { + if !g.keyReturns[c] { + return false + } + } + + return len(callees) > 0 && !g.isTainted(info, x) + case *ast.Ident: + obj := info.Uses[x] + + return obj != nil && g.keyed[obj] && !g.raw(g.tainted[obj]) + case *ast.SelectorExpr: + selection, ok := info.Selections[x] + if !ok || selection.Kind() != types.FieldVal { + return false + } + + f := selection.Obj() + + return f.Name() == "Name" && f.Pkg() != nil && f.Pkg().Path() == "k8s.io/apimachinery/pkg/apis/meta/v1" + } + + return false +} + +// nameLike reports whether e holds an object name, raw or as a key. +func (g *guard) nameLike(info *types.Info, e ast.Expr) bool { + return g.isTainted(info, e) || g.isKey(info, e) +} + +// isSourceField reports whether a selector reads a name field of a source +// type. +func isSourceField(info *types.Info, sel *ast.SelectorExpr) bool { + selection, ok := info.Selections[sel] + if !ok || selection.Kind() != types.FieldVal { + return false + } + + recv := types.Unalias(selection.Recv()) + if p, ok := recv.(*types.Pointer); ok { + recv = types.Unalias(p.Elem()) + } + + named, ok := recv.(*types.Named) + if !ok || named.Obj().Pkg() == nil { + return false + } + + // A satellite controller is configured with the node it runs on, in + // the spelling the operator gave; the kernel names it also holds are + // keys already. + if named.Obj().Pkg().Path() == module+"/pkg/satellite/controllers" { + return sel.Sel.Name == "NodeName" + } + + if !sourcePackages()[named.Obj().Pkg().Path()] { + return false + } + + name := sel.Sel.Name + if name == "Name" { + return namedTypes()[named.Obj().Pkg().Path()+"."+named.Obj().Name()] + } + + return sourceFields()[name] +} + +// passThrough are the calls whose result holds whatever names their +// arguments do. +func passThrough() map[string]bool { + return map[string]bool{ + "strings.TrimSpace": true, "strings.ToLower": true, "strings.ToUpper": true, "strings.Trim": true, + "strings.TrimPrefix": true, "strings.TrimSuffix": true, "strings.TrimLeft": true, "strings.TrimRight": true, + "strings.Split": true, "strings.SplitN": true, "strings.Cut": true, "strings.CutPrefix": true, + "strings.CutSuffix": true, "strings.Fields": true, "strings.Join": true, + "fmt.Sprintf": true, "fmt.Sprint": true, "path.Join": true, "slices.Clone": true, "slices.Sorted": true, + } +} + +// labels is where a value's names may come from: src, for a source, or a +// parameter of the function it sits in, for whatever a caller passes there. +// Keeping parameters apart lets a helper that returns what it is given +// return a name to the callers that give it one and to no other. +type labels map[any]bool + +type srcLabel struct{} + +func union(sets ...labels) labels { + var out labels + + for _, s := range sets { + for l := range s { + if out == nil { + out = labels{} + } + + out[l] = true + } + } + + return out +} + +// raw reports whether a value with these labels may hold a name as some +// caller spelled it: it comes from a source, or from a parameter some call +// passes one into. +func (g *guard) raw(set labels) bool { + for l := range set { + if l == (srcLabel{}) { + return true + } + + if p, ok := l.(types.Object); ok && g.reached[p] { + return true + } + } + + return false +} + +// isTainted reports whether e may hold an object name in a spelling of its +// own: read from a source, or derived from one. +func (g *guard) isTainted(info *types.Info, e ast.Expr) bool { + return g.raw(g.labelsOf(info, e)) +} + +func (g *guard) labelsOf(info *types.Info, e ast.Expr) labels { + switch x := ast.Unparen(e).(type) { + case *ast.Ident: + obj := info.Uses[x] + if obj == nil { + obj = info.Defs[x] + } + + return g.tainted[obj] + case *ast.SelectorExpr: + if isSourceField(info, x) { + return labels{srcLabel{}: true} + } + + if selection, ok := info.Selections[x]; ok && selection.Kind() == types.FieldVal { + return g.tainted[selection.Obj()] + } + case *ast.IndexExpr: + if isFlagSource(info, x) { + return labels{srcLabel{}: true} + } + + return g.labelsOf(info, x.X) + case *ast.SliceExpr: + return g.labelsOf(info, x.X) + case *ast.StarExpr: + return g.labelsOf(info, x.X) + case *ast.TypeAssertExpr: + return g.labelsOf(info, x.X) + case *ast.BinaryExpr: + if x.Op == token.ADD { + return union(g.labelsOf(info, x.X), g.labelsOf(info, x.Y)) + } + case *ast.CompositeLit: + if _, ok := info.TypeOf(x).Underlying().(*types.Struct); ok { + return nil + } + + var out labels + + for _, elt := range x.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok { + elt = kv.Value + } + + out = union(out, g.labelsOf(info, elt)) + } + + return out + case *ast.CallExpr: + return g.callLabels(info, x) + } + + return nil +} + +func (g *guard) argLabels(info *types.Info, args []ast.Expr) labels { + var out labels + + for _, arg := range args { + out = union(out, g.labelsOf(info, arg)) + } + + return out +} + +func (g *guard) anyTainted(info *types.Info, args []ast.Expr) bool { + return g.raw(g.argLabels(info, args)) +} + +func (g *guard) callLabels(info *types.Info, call *ast.CallExpr) labels { + if isKeyCall(info, call) { + return nil + } + + if isSourceCall(info, call) { + return labels{srcLabel{}: true} + } + + if tv, ok := info.Types[call.Fun]; ok && tv.IsType() && len(call.Args) == 1 { + return g.labelsOf(info, call.Args[0]) + } + + if id, ok := ast.Unparen(call.Fun).(*ast.Ident); ok { + if b, ok := info.Uses[id].(*types.Builtin); ok { + if b.Name() == "append" { + return g.argLabels(info, call.Args) + } + + return nil + } + } + + var out labels + + for _, key := range g.callees(info, call) { + out = union(out, g.substitute(info, key, call)) + } + + f := calleeOf(info, call) + if funcName(f) == "maps.Keys" && len(call.Args) == 1 && g.mapKeys[mapObj(info, call.Args[0])] { + return union(out, labels{srcLabel{}: true}) + } + + if passThrough()[funcName(f)] { + return union(out, g.argLabels(info, call.Args)) + } + + // A method of a value that holds names outside the tree, such as + // url.Values.Get, returns one of them. + if sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr); ok && f != nil && g.fns[f] == nil && isString(info, call) { + return union(out, g.labelsOf(info, sel.X)) + } + + return out +} + +// substitute is what a call returns: what its callee returns, with each of +// the callee's parameters replaced by what this call passes there. +func (g *guard) substitute(info *types.Info, key any, call *ast.CallExpr) labels { + ret := g.returns[key] + if len(ret) == 0 { + return nil + } + + params := g.params(key) + + var out labels + + for l := range ret { + obj, _ := l.(types.Object) + + i := -1 + if obj != nil { + i = slices.Index(params, obj) + } + + if i < 0 { + // A source, or the parameter of a function a closure sits + // in, which means the same in the caller. + out = union(out, labels{l: true}) + + continue + } + + if i == len(params)-1 && len(call.Args) > len(params) { + out = union(out, g.argLabels(info, call.Args[i:])) + } else if i < len(call.Args) { + out = union(out, g.labelsOf(info, call.Args[i])) + } + } + + return out +} + +// holdsStrings reports whether a value of t can hold a name: a string, or a +// pointer, slice, array or map of them. A struct is never a name; its +// fields are followed one by one. +func holdsStrings(t types.Type) bool { + switch u := t.Underlying().(type) { + case *types.Basic: + return u.Info()&types.IsString != 0 + case *types.Pointer: + return holdsStrings(u.Elem()) + case *types.Slice: + return holdsStrings(u.Elem()) + case *types.Array: + return holdsStrings(u.Elem()) + case *types.Map: + return holdsStrings(u.Elem()) + } + + return false +} + +// add puts set into obj's labels. A field is shared by every value of its +// type, so it takes only whether the value is raw, never a parameter that +// means something in one function alone. +func (g *guard) add(obj types.Object, set labels) bool { + if obj == nil || len(set) == 0 || !holdsStrings(obj.Type()) { + return false + } + + if v, ok := obj.(*types.Var); ok && v.IsField() { + if !g.raw(set) { + return false + } + + set = labels{srcLabel{}: true} + } + + cur := g.tainted[obj] + changed := false + + for l := range set { + if !cur[l] { + if cur == nil { + cur = labels{} + g.tainted[obj] = cur + } + + cur[l] = true + changed = true + } + } + + return changed +} + +// taintableField reports whether writes taint a field: a string field of a +// struct of this module whose name fields are not already declared as +// sources. A collection field would taint every element of every value +// for one write, which says nothing. +func taintableField(obj types.Object) bool { + if obj.Pkg() == nil || !strings.HasPrefix(obj.Pkg().Path(), module) || sourcePackages()[obj.Pkg().Path()] { + return false + } + + b, ok := obj.Type().Underlying().(*types.Basic) + + return ok && b.Info()&types.IsString != 0 +} + +// propagate follows sources through assignments, ranges, literals, calls +// and returns until nothing changes. +func (g *guard) propagate() { + for key := range g.fns { + for _, p := range g.params(key) { + if p != nil { + g.add(p, labels{p: true}) + } + } + } + + for changed := true; changed; { + changed = false + + for key, f := range g.fns { + info := f.info + + ast.Inspect(f.body, func(n ast.Node) bool { + switch s := n.(type) { + case *ast.FuncLit: + // Walked as a body of its own. + return false + case *ast.AssignStmt: + changed = g.assign(info, s.Lhs, s.Rhs) || changed + case *ast.ValueSpec: + lhs := make([]ast.Expr, len(s.Names)) + for i, id := range s.Names { + lhs[i] = id + } + + changed = g.assign(info, lhs, s.Values) || changed + case *ast.RangeStmt: + // A map's values are names when it holds names, and + // its keys when something keyed it by one. + if s.Value != nil { + changed = g.markExpr(info, s.Value, g.labelsOf(info, s.X)) || changed + } + + if s.Key != nil && isStringMap(info, s.X) && g.mapKeys[mapObj(info, s.X)] { + changed = g.markExpr(info, s.Key, labels{srcLabel{}: true}) || changed + } + case *ast.ReturnStmt: + if !g.keyReturns[key] && len(s.Results) > 0 && g.isKey(info, s.Results[0]) { + g.keyReturns[key] = true + changed = true + } + + set := g.argLabels(info, s.Results) + if len(set) > 0 { + before := len(g.returns[key]) + g.returns[key] = union(g.returns[key], set) + changed = len(g.returns[key]) != before || changed + } + case *ast.CompositeLit: + changed = g.fields(info, s) || changed + case *ast.CallExpr: + changed = g.passArgs(info, s) || changed + } + + return true + }) + } + } +} + +// fields taints the fields a struct literal sets from a name. +func (g *guard) fields(info *types.Info, lit *ast.CompositeLit) bool { + st, ok := info.TypeOf(lit).Underlying().(*types.Struct) + if !ok { + return false + } + + changed := false + + for i, elt := range lit.Elts { + field := (*types.Var)(nil) + value := elt + + if kv, ok := elt.(*ast.KeyValueExpr); ok { + value = kv.Value + + if id, ok := kv.Key.(*ast.Ident); ok { + field, _ = info.Uses[id].(*types.Var) + } + } else if i < st.NumFields() { + field = st.Field(i) + } + + if field != nil && taintableField(field) { + changed = g.add(field, g.labelsOf(info, value)) || changed + } + } + + return changed +} + +func identObj(info *types.Info, e ast.Expr) types.Object { + id, ok := ast.Unparen(e).(*ast.Ident) + if !ok || id.Name == "_" { + return nil + } + + if obj := info.Defs[id]; obj != nil { + return obj + } + + return info.Uses[id] +} + +// target is what a write to e taints: a variable, a field of this module's +// structs, or the whole collection an element is written into. +func target(info *types.Info, e ast.Expr) types.Object { + switch x := ast.Unparen(e).(type) { + case *ast.Ident: + return identObj(info, x) + case *ast.SelectorExpr: + if selection, ok := info.Selections[x]; ok && selection.Kind() == types.FieldVal && taintableField(selection.Obj()) { + return selection.Obj() + } + case *ast.IndexExpr: + return target(info, x.X) + case *ast.StarExpr: + return target(info, x.X) + } + + return nil +} + +func (g *guard) markExpr(info *types.Info, e ast.Expr, set labels) bool { + return g.add(target(info, e), set) +} + +func (g *guard) markKeyed(info *types.Info, e ast.Expr) bool { + obj := identObj(info, e) + if obj == nil || g.keyed[obj] { + return false + } + + g.keyed[obj] = true + + return true +} + +func (g *guard) assign(info *types.Info, lhs, rhs []ast.Expr) bool { + changed := false + + switch { + case len(lhs) == len(rhs): + for i := range lhs { + if set := g.labelsOf(info, rhs[i]); len(set) > 0 { + changed = g.markExpr(info, lhs[i], set) || changed + } else if g.isKey(info, rhs[i]) { + changed = g.markKeyed(info, lhs[i]) || changed + } + } + case len(rhs) == 1 && len(lhs) > 0: + set := g.labelsOf(info, rhs[0]) + for i := range lhs { + changed = g.markExpr(info, lhs[i], set) || changed + } + } + + for i := range lhs { + ix, ok := ast.Unparen(lhs[i]).(*ast.IndexExpr) + if !ok || !isStringMap(info, ix.X) || !g.rawKey(info, ix.Index) { + continue + } + + if m := mapObj(info, ix.X); m != nil && !g.mapKeys[m] { + g.mapKeys[m] = true + changed = true + } + } + + return changed +} + +// mapObj is the variable or field a map expression reads. +func mapObj(info *types.Info, e ast.Expr) types.Object { + switch x := ast.Unparen(e).(type) { + case *ast.Ident: + return identObj(info, x) + case *ast.SelectorExpr: + if selection, ok := info.Selections[x]; ok && selection.Kind() == types.FieldVal { + return selection.Obj() + } + } + + return nil +} + +func (g *guard) reach(p types.Object) bool { + if p == nil || g.reached[p] { + return false + } + + g.reached[p] = true + + return true +} + +// passArgs records which parameters some call passes a raw name into and, +// the other way, taints what a call hands the store as a name. +func (g *guard) passArgs(info *types.Info, call *ast.CallExpr) bool { + changed := false + f := calleeOf(info, call) + + if f != nil && f.Pkg() != nil && f.Pkg().Path() == module+"/pkg/store" { + sig, _ := f.Type().(*types.Signature) + for i, arg := range call.Args { + if sig != nil && i < sig.Params().Len() && storeParams()[sig.Params().At(i).Name()] && + isString(info, arg) && !g.isKey(info, arg) { + changed = g.markExpr(info, arg, labels{srcLabel{}: true}) || changed + } + } + } + + // slices.ContainsFunc(names, func(n string) bool { ... }) hands the + // function each element. + if name := funcName(f); strings.HasPrefix(name, "slices.") && strings.HasSuffix(name, "Func") && + len(call.Args) >= 2 && g.isTainted(info, call.Args[0]) { + fake := &ast.CallExpr{Fun: call.Args[len(call.Args)-1]} + for _, key := range g.callees(info, fake) { + if params := g.params(key); len(params) > 0 { + changed = g.reach(params[0]) || changed + } + } + } + + for _, key := range g.callees(info, call) { + params := g.params(key) + + for i, arg := range call.Args { + p := min(i, len(params)-1) + if p >= 0 && g.isTainted(info, arg) { + changed = g.reach(params[p]) || changed + } + } + } + + return changed +} + +// sameShape reports whether two operands read one field off elements of one +// collection, by index or as the two arguments of a sort's comparator: one +// source, not two spellings. +func (g *guard) sameShape(info *types.Info, a, b ast.Expr) bool { + sa, oka := ast.Unparen(a).(*ast.SelectorExpr) + sb, okb := ast.Unparen(b).(*ast.SelectorExpr) + + if !oka || !okb || sa.Sel.Name != sb.Sel.Name { + return false + } + + if pa, pb := identObj(info, sa.X), identObj(info, sb.X); pa != nil && g.siblings[pa] == pb { + return true + } + + ia, oka := ast.Unparen(sa.X).(*ast.IndexExpr) + ib, okb := ast.Unparen(sb.X).(*ast.IndexExpr) + + if !oka || !okb || types.ExprString(ia.X) != types.ExprString(ib.X) { + return false + } + + ra, rb := root(info, ia.X), root(info, ib.X) + + return ra != nil && ra == rb +} + +// root is the variable an expression such as s.items or list reads from. +func root(info *types.Info, e ast.Expr) types.Object { + switch x := ast.Unparen(e).(type) { + case *ast.Ident: + return identObj(info, x) + case *ast.SelectorExpr: + return root(info, x.X) + } + + return nil +} + +func isCRAddress(info *types.Info, lit *ast.CompositeLit) bool { + t := info.TypeOf(lit) + if t == nil { + return false + } + + // client.ObjectKey is an alias of types.NamespacedName. + named, ok := types.Unalias(t).(*types.Named) + if !ok || named.Obj().Pkg() == nil { + return false + } + + switch named.Obj().Pkg().Path() + "." + named.Obj().Name() { + case "k8s.io/apimachinery/pkg/types.NamespacedName", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": + return true + } + + return false +} + +type finding struct { + file string + line int + code string +} + +// violations reports, keyed "path: code", every sink a source reaches +// without naming.Key or naming.Same, in the files under root. +func (g *guard) violations(root string) []string { + found := g.findings(root) + out := make([]string, 0, len(found)) + + for _, f := range found { + out = append(out, f.file+": "+f.code) + } + + sort.Strings(out) + + return slices.Compact(out) +} + +func (g *guard) findings(root string) []finding { + var out []finding + + for _, pkg := range g.pkgs { + info := pkg.TypesInfo + + for _, file := range pkg.Syntax { + path := pkg.Fset.File(file.Pos()).Name() + + rel, err := filepath.Rel(root, path) + if err != nil { + continue + } + + rel = filepath.ToSlash(rel) + if strings.HasSuffix(rel, "_test.go") || excluded(rel) { + continue + } + + src, err := os.ReadFile(path) + if err != nil { + continue + } + + code := func(n ast.Node) string { + return strings.Join(strings.Fields(string(src[pkg.Fset.Position(n.Pos()).Offset:pkg.Fset.Position(n.End()).Offset])), " ") + } + + ast.Inspect(file, func(n ast.Node) bool { + if decl, ok := n.(*ast.FuncDecl); ok { + if _, skip := excludedFuncs()[rel+":"+funcKey(decl)]; skip { + return false + } + } + + for _, hit := range g.sinks(info, n) { + out = append(out, finding{file: rel, line: pkg.Fset.Position(hit.Pos()).Line, code: code(hit)}) + } + + return true + }) + } + } + + return out +} + +// rawPair reports whether two operands both hold object names and at least +// one of them is raw: two spellings compared as they are. +func (g *guard) rawPair(info *types.Info, x, y ast.Expr) bool { + if isConstant(info, x) || isConstant(info, y) || g.sameShape(info, x, y) { + return false + } + + return g.isTainted(info, x) && g.nameLike(info, y) || g.isTainted(info, y) && g.nameLike(info, x) +} + +// rawKey reports whether e, used as a map key, is a raw name or built from +// one. +func (g *guard) rawKey(info *types.Info, e ast.Expr) bool { + return !isConstant(info, e) && !isKeyCall(info, e) && g.isTainted(info, e) +} + +func isStringMap(info *types.Info, e ast.Expr) bool { + t := info.TypeOf(e) + if t == nil { + return false + } + + m, ok := t.Underlying().(*types.Map) + if !ok { + return false + } + + k, ok := m.Key().Underlying().(*types.Basic) + + return ok && k.Info()&types.IsString != 0 +} + +// sinks returns the nodes to report when n is a sink a source reaches. +func (g *guard) sinks(info *types.Info, n ast.Node) []ast.Node { + switch e := n.(type) { + case *ast.BinaryExpr: + if (e.Op == token.EQL || e.Op == token.NEQ) && isString(info, e.X) && isString(info, e.Y) && g.rawPair(info, e.X, e.Y) { + return []ast.Node{e} + } + case *ast.SwitchStmt: + if e.Tag == nil || !isString(info, e.Tag) { + return nil + } + + var out []ast.Node + + for _, stmt := range e.Body.List { + for _, c := range stmt.(*ast.CaseClause).List { + if g.rawPair(info, e.Tag, c) { + out = append(out, c) + } + } + } + + return out + case *ast.IndexExpr: + if isStringMap(info, e.X) && g.rawKey(info, e.Index) { + return []ast.Node{e} + } + case *ast.CallExpr: + return g.callSinks(info, e) + case *ast.CompositeLit: + return g.literalSinks(info, e) + } + + return nil +} + +func (g *guard) callSinks(info *types.Info, call *ast.CallExpr) []ast.Node { + if id, ok := ast.Unparen(call.Fun).(*ast.Ident); ok { + if b, ok := info.Uses[id].(*types.Builtin); ok && b.Name() == "delete" && len(call.Args) == 2 && + isStringMap(info, call.Args[0]) && g.rawKey(info, call.Args[1]) { + return []ast.Node{call} + } + } + + switch funcName(calleeOf(info, call)) { + case "slices.Contains", "slices.Index", "strings.HasPrefix", "strings.HasSuffix", "strings.Contains", + "strings.CutPrefix", "strings.CutSuffix": + if len(call.Args) == 2 && g.rawPair(info, call.Args[0], call.Args[1]) { + return []ast.Node{call} + } + case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": + if g.anyTainted(info, call.Args) { + return []ast.Node{call} + } + } + + return nil +} + +func (g *guard) literalSinks(info *types.Info, lit *ast.CompositeLit) []ast.Node { + var out []ast.Node + + switch { + case isStringMap(info, lit): + for _, elt := range lit.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok && g.rawKey(info, kv.Key) { + out = append(out, kv) + } + } + case isCRAddress(info, lit): + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Name" && !isKeyCall(info, kv.Value) && g.isTainted(info, kv.Value) { + out = append(out, kv) + } + } + } + + return out +} + +// excludedDirs are the trees the rule does not reach, and why. +func excludedDirs() map[string]string { + return map[string]string{ + "pkg/naming": "defines the key", + "pkg/store/k8s": "the store backend: it resolves a caller's spelling to the CR name, " + + "and its scoped reads ask in one given spelling at a time, as the field selector does", + "pkg/store/storetest": "the conformance suite, which writes spellings on purpose", + "third_party": "vendored code", + } +} + +// excludedFuncs are single functions out of reach, as "path:Func" or +// "path:Type.Method", and why. The rest of each file is checked. +func excludedFuncs() map[string]string { + device := "the in-memory store backend for physical devices, keyed by the device name and asked " + + "for a node in one spelling, as the Kubernetes one is" + fake := "the scripted command runner tests drive the storage backends with, keyed by the exact " + + "command line a test expects" + + kernel := "names the kernel reports, compared and keyed only with each other" + + return map[string]string{ + "pkg/drbd/drbdadm.go:Adm.Day0SiblingSetConnected": kernel, + "pkg/drbd/drbdsetup_show.go:parseShowJSON": kernel, + "pkg/store/inmemory_physicaldevice.go:inMemoryPhysicalDevices.List": device, + "pkg/store/inmemory_physicaldevice.go:inMemoryPhysicalDevices.ListForNode": device, + "pkg/store/inmemory_physicaldevice.go:inMemoryPhysicalDevices.Get": device, + "pkg/store/inmemory_physicaldevice.go:inMemoryPhysicalDevices.PatchPhysicalDeviceSpec": device, + "pkg/store/inmemory_physicaldevice.go:inMemoryPhysicalDevices.Delete": device, + "pkg/storage/exec.go:FakeExec.RunWithStdin": fake, + } +} + +func funcKey(decl *ast.FuncDecl) string { + if decl.Recv == nil || len(decl.Recv.List) == 0 { + return decl.Name.Name + } + + t := decl.Recv.List[0].Type + if star, ok := t.(*ast.StarExpr); ok { + t = star.X + } + + if idx, ok := t.(*ast.IndexExpr); ok { + t = idx.X + } + + if id, ok := t.(*ast.Ident); ok { + return id.Name + "." + decl.Name.Name + } + + return decl.Name.Name +} + +func excluded(rel string) bool { + for dir := range excludedDirs() { + if strings.HasPrefix(rel, dir+"/") { + return true + } + } + + return false +} + +// allowed are reviewed sites the rule reports whose names all come from one +// source and must stay as they are, and why. +func allowed() map[string]string { + oneListing := "keys and lookups both come from one listing, so they spell each name alike" + oneIntent := "option keys the dispatcher writes and the satellite reads back by the intent's own peer " + + "names, the one list both sides iterate" + shownKeys := "a wire map whose keys are shown, filled from one listing" + satIntent := "the satellite's map of one intent's peers, written and read by the intent's own peer names" + pools := "the satellite resolves a provider under the pool name the StoragePool registered; a replica " + + "naming its pool in another spelling fails loudly as not registered, and keying the map would change " + + "the pool names its other readers see" + + return map[string]string{ + "pkg/rest/node_connections.go: delete(conn, key)": "" + + "the key is one storedPairKeys read off the same map", + "pkg/rest/resource_connections.go: delete(rd.Props, key)": "" + + "pairPropKey finds the pair's stored prop by naming.Same", + "pkg/satellite/reconciler.go: opts[\"peer.\"+peer+\".address\"]": oneIntent, + "pkg/satellite/reconciler.go: opts[\"peer.\"+peer+\".diskless\"]": oneIntent, + "pkg/satellite/reconciler.go: opts[\"peer.\"+peer+\".node-id\"]": oneIntent, + "pkg/satellite/reconciler.go: opts[\"peer.\"+peer+\".port\"]": oneIntent, + "pkg/satellite/reconciler.go: out[peer]": satIntent, + "pkg/satellite/reconciler.go: delete(r.cfg.Providers, pool)": pools, + "pkg/satellite/reconciler.go: r.cfg.Providers[pool]": pools, + "pkg/satellite/reconciler.go: r.resourceToPool[name]": "" + + "keyed by the resource name the intent carries, the definition's CR name, on write and read", + "pkg/dispatcher/dispatcher.go: drbdOpts[\"peer.\"+peer+\".address\"]": oneIntent, + "pkg/dispatcher/dispatcher.go: drbdOpts[\"peer.\"+peer+\".diskless\"]": oneIntent, + "pkg/dispatcher/dispatcher.go: drbdOpts[\"peer.\"+peer+\".node-id\"]": oneIntent, + "pkg/dispatcher/dispatcher.go: drbdOpts[\"peer.\"+peer+\".port\"]": oneIntent, + "pkg/rest/snapshots.go: objRefs[\"CreateTimestamp/\"+node.NodeName]": shownKeys, + "pkg/rest/snapshots.go: objRefs[\"SnapshotUuid/\"+node.NodeName]": shownKeys, + "pkg/satellite/kernel_cleanup.go: expectedSet[n]": "" + + "the intent's peer names, against kernel peer slots the satellite named from the same intent", + "pkg/store/cascade.go: slices.Contains(spellings, spelling)": "" + + "the store's own list of the spellings it asks under, which differ on purpose; deduplicated as written", + "internal/cli/definition.go: seen[key]": "" + + "dedups the replicas of one definition, one per node, so no key meets another spelling of itself", + "internal/cli/handlers.go: seen[name]": oneListing, + "internal/cli/handlers.go: sizes[name]": oneListing, + "internal/cli/view/resource.go: in.VolumeSizesKib[res.Name]": oneListing, + "pkg/rest/node_connections.go: conn[key]": "" + + "the key is one storedPairKeys read off the same map", + "pkg/rest/node_connections.go: ctrlConfig.Spec.NodeConnections[pairKeyIn(ctrlConfig.Spec.NodeConnections, src, dst)]": "" + + "pairKeyIn finds the pair's stored key by naming.Same", + "pkg/rest/physical_storage.go: entry.Nodes[devs[i].NodeName]": "" + + "a wire map whose keys are shown; filled and read from one device listing", + "pkg/rest/query_size_info.go: result[rgs[i].Name]": "" + + "a wire map whose keys are shown, one entry per stored group", + "pkg/rest/resource_connections.go: rd.Props[key]": "" + + "pairPropKey finds the pair's stored prop by naming.Same", + "pkg/rest/snapshots.go: seen[key]": "" + + "dedups the replicas of one definition, one per node, so no key meets another spelling of itself", + "pkg/store/clone_snapshot.go: l.InUse[snapshot]": "" + + "reads back the keys of the same map it ranges over", + "pkg/store/clone_snapshot.go: out.InUse[snaps[i].Name]": "" + + "keys are shown in the operator's message; filled and read from one listing", + } +} + +func moduleRoot(t *testing.T) string { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + + return root +} + +// Code above the store compares and indexes object names only as keys. Every +// earlier fix to name handling fixed one more place that compared two +// spellings as they were, or folded case where the store hashes; this keeps +// the class from coming back. A hit is fixed with naming.Key or naming.Same, +// or, when both sides come from one source and must stay as they are, added +// to allowed with the reason. +func TestNamesAreComparedAsKeys(t *testing.T) { + t.Parallel() + + root := moduleRoot(t) + reviewed := allowed() + seen := map[string]bool{} + + for _, v := range newGuard(t, root, "./...").violations(root) { + seen[v] = true + + if _, ok := reviewed[v]; !ok { + t.Errorf("%s: compare or key the name with naming.Key or naming.Same", v) + } + } + + for v := range reviewed { + if !seen[v] { + t.Errorf("allowed entry %q matches nothing any more; remove it", v) + } + } +} + +// The guard follows where a name comes from, not what it is called: every +// planted shape is caught under names no vocabulary would guess, and the +// right ways to compare are left alone. +func TestTheGuardCatchesPlantedShapes(t *testing.T) { + t.Parallel() + + dir := filepath.Join(moduleRoot(t), "pkg", "naming", "testdata", "planted") + + src, err := os.ReadFile(filepath.Join(dir, "planted.go")) + if err != nil { + t.Fatal(err) + } + + var want []string + + for i, line := range strings.Split(string(src), "\n") { + if strings.HasSuffix(line, "// want") { + want = append(want, strconv.Itoa(i+1)) + } + } + + found := newGuard(t, dir, ".").findings(dir) + got := make([]string, 0, len(found)) + + for _, f := range found { + got = append(got, strconv.Itoa(f.line)) + } + + sort.Strings(got) + sort.Strings(want) + + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("reported lines %v, want %v", got, want) + } +} + +func TestInventory(t *testing.T) { + if os.Getenv("NAMING_INVENTORY") == "" { + t.Skip("set NAMING_INVENTORY=1 to print every hit") + } + + root := moduleRoot(t) + + for _, v := range newGuard(t, root, "./...").violations(root) { + t.Log(v) + } +} diff --git a/pkg/naming/naming.go b/pkg/naming/naming.go new file mode 100644 index 00000000..bf5d1328 --- /dev/null +++ b/pkg/naming/naming.go @@ -0,0 +1,111 @@ +// 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 naming holds the one identity LINSTOR names have in blockstor. +// +// LINSTOR names are case-insensitive, and the API server takes only +// RFC 1123 names, so every object is stored under the key Key gives its +// name: the lowercase name when that is a valid RFC 1123 name, and a short +// hash of the name as given, followed by a slug, otherwise. The key is the +// object's CR name. Node-1 and node-1 are one node; My_Res and my_res hash +// to two keys and are two definitions, as they are two objects in the store. +// +// A name arrives in several spellings: as an operator or a client typed it, +// as it was registered (kept in annotations for display), as a child row +// stored it in spec, and as the CR name itself. Key maps every one of them to +// the same key, and is idempotent on a key, so code above the store compares +// and indexes node, definition, group, pool and snapshot names only through +// Key or Same, never by folding case or by comparing two spellings as they +// are. A test in this package walks the source and fails on anything else. +package naming + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" +) + +// rfc1123 is the API server's metadata.Name rule. +var rfc1123 = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +const ( + // maxName is the metadata.Name length cap. + maxName = 253 + + // hashPrefixBytes is the length of the hash a name outside RFC 1123 + // is keyed by: 8 hex digits. + hashPrefixBytes = 4 +) + +// Key is the identity of a LINSTOR name: the CR name the store keeps the +// object under. It lowercases first, and returns that when it is a valid +// RFC 1123 name. Otherwise it replaces every run of other characters with +// '-' and prefixes a hash of the name as given, so two names that differ +// only in case outside RFC 1123 stay two keys. +func Key(in string) string { + lower := strings.ToLower(in) + + if lower != "" && len(lower) <= maxName && rfc1123.MatchString(lower) { + return lower + } + + var builder strings.Builder + + prevDash := true + + for _, r := range lower { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + builder.WriteRune(r) + + prevDash = false + default: + if !prevDash { + builder.WriteByte('-') + + prevDash = true + } + } + } + + slug := strings.Trim(builder.String(), "-") + + digest := sha256.Sum256([]byte(in)) + out := hex.EncodeToString(digest[:hashPrefixBytes]) + + // A key that ends on a dash, from a name with no letter or digit or from + // a cut, is not a name the API server takes, and Key of it would hash it + // again. No stored object carries one, so leaving the dash out keeps + // Key idempotent on every key it returns and changes no existing name. + if slug != "" { + out += "-" + slug + } + + if len(out) > maxName { + out = strings.TrimRight(out[:maxName], "-") + } + + return out +} + +// Same reports whether two spellings name the same object: whether they +// have the same Key. +func Same(a, b string) bool { + return Key(a) == Key(b) +} diff --git a/pkg/naming/naming_test.go b/pkg/naming/naming_test.go new file mode 100644 index 00000000..262dcc43 --- /dev/null +++ b/pkg/naming/naming_test.go @@ -0,0 +1,76 @@ +// 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 naming_test + +import ( + "strings" + "testing" + + "github.com/cozystack/blockstor/pkg/naming" +) + +func TestKey(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ in, want string }{ + {"pvc-1", "pvc-1"}, + {"PVC-1", "pvc-1"}, + {"Node-1", "node-1"}, + {"DfltRscGrp", "dfltrscgrp"}, + } { + if got := naming.Key(tc.in); got != tc.want { + t.Errorf("Key(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// Names outside RFC 1123 hash the name as given, so two case variants are +// two keys, as they are two objects in the store. +func TestKeyKeepsCaseVariantsOutsideRFC1123Apart(t *testing.T) { + t.Parallel() + + if naming.Same("My_Res", "my_res") { + t.Error("My_Res and my_res share a key") + } + + if !naming.Same("My_Res", "My_Res") || !naming.Same("PVC-X", "pvc-x") { + t.Error("one name in two RFC 1123 spellings, or one spelling twice, is two keys") + } +} + +// Key is idempotent: a key is its own key, so every spelling, a key +// included, compares the same way. A long name cut on a dash would not be. +func TestKeyIsIdempotent(t *testing.T) { + t.Parallel() + + for _, in := range []string{ + "pvc-1", "Node-1", "My_Res", "my_res", "a.b_C", "_", "--", "", + strings.Repeat("ab_", 100), + strings.Repeat("a", 243) + "_" + strings.Repeat("b", 20), + } { + key := naming.Key(in) + if again := naming.Key(key); again != key { + t.Errorf("Key(Key(%q)) = %q, want %q", in, again, key) + } + + if len(key) > 253 { + t.Errorf("Key(%q) is %d long", in, len(key)) + } + } +} diff --git a/pkg/naming/testdata/planted/planted.go b/pkg/naming/testdata/planted/planted.go new file mode 100644 index 00000000..200b4756 --- /dev/null +++ b/pkg/naming/testdata/planted/planted.go @@ -0,0 +1,327 @@ +// 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 planted holds the shapes the name guard must catch, each under +// variable names no vocabulary would guess, and the shapes it must leave +// alone. Lines ending in "// want" are the ones it reports. +package planted + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + crd "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store" +) + +// The migrate-disk shape: two names reach one comparison through a +// parameter, from two path parameters. +func moveIt(req *http.Request) bool { + p := req.PathValue("node") + + return twoEnds(req, p) +} + +func twoEnds(req *http.Request, u string) bool { + w := req.PathValue("dst") + + return u == w // want +} + +// The same, with both names known only as what the store is asked for. +func askTwice(ctx context.Context, st store.Store, q1, q2 string) bool { + _, _ = st.Resources().Get(ctx, "pvc", q1) + _, _ = st.Nodes().Get(ctx, q2) + + return q1 != q2 // want +} + +// The node-connection filter shape: a wire field against a path parameter. +func pick(req *http.Request, rows []apiv1.Resource) int { + zz := req.PathValue("node") + hits := 0 + + for i := range rows { + yy := rows[i].NodeName + if yy == zz { // want + hits++ + } + } + + return hits +} + +// A name a helper returns, compared with another. +func spelled(req *http.Request) string { return req.PathValue("rd") } + +func viaReturn(req *http.Request, snap apiv1.Snapshot) bool { + return spelled(req) == snap.ResourceName // want +} + +// A name ranged out of a list. +func ranged(req *http.Request, snap apiv1.Snapshot) bool { + target := req.PathValue("node") + + for _, el := range snap.Nodes { + if el == target { // want + return true + } + } + + return false +} + +// A map keyed by one spelling and read by another. +func indexed(req *http.Request, byX map[string]int) int { + k9 := req.PathValue("node") + + return byX[k9] // want +} + +// A search of a list of names. +func searched(req *http.Request, snap apiv1.Snapshot) bool { + return slices.Contains(snap.Nodes, req.PathValue("node")) // want +} + +// Case folded where the store hashes. +func folded(req *http.Request, r apiv1.Resource) bool { + return strings.EqualFold(r.NodeName, req.PathValue("node")) // want +} + +// A CR addressed by a raw name. +func addressed(req *http.Request) types.NamespacedName { + return types.NamespacedName{Name: req.PathValue("rd")} // want +} + +// The same through controller-runtime's alias. +func aliased(req *http.Request) client.ObjectKey { + return client.ObjectKey{Name: req.PathValue("node")} // want +} + +// A CR's own name, a key, against a raw one. +func crName(req *http.Request, obj *crd.ResourceDefinition) bool { + return obj.Name == req.PathValue("rd") // want +} + +// The shapes a second review planted. + +// Two wire rows of two different listings, one field each. +func twoListings(a apiv1.Resource, b apiv1.Snapshot) bool { + return a.Name == b.ResourceName // want +} + +// The same field of two values that are not one collection. +func twoValues(a, b apiv1.Resource) bool { + return a.NodeName == b.NodeName // want +} + +// A key built inline from a raw name. +func concatKey(r apiv1.Resource, pool string, byPair map[string]int) int { + return byPair[r.NodeName+"/"+pool] // want +} + +// A key built with Sprintf. +func sprintfKey(r apiv1.Resource, byPair map[string]int) int { + return byPair[fmt.Sprintf("%s/%s", r.NodeName, r.Name)] // want +} + +type holder struct{ who string } + +// A name copied through a field of a local struct. +func viaField(req *http.Request, r apiv1.Resource) bool { + h := holder{who: req.PathValue("node")} + + return h.who == r.NodeName // want +} + +// A switch on one name with a case of another. +func switched(req *http.Request, r apiv1.Resource) int { + switch req.PathValue("node") { + case r.NodeName: // want + return 1 + } + + return 0 +} + +// A closure bound to a variable. +func viaClosure(req *http.Request, r apiv1.Resource) bool { + eq := func(x, y string) bool { + return x == y // want + } + + return eq(req.PathValue("node"), r.NodeName) +} + +type namer interface{ Name() string } + +type pathNamer struct{ req *http.Request } + +func (p pathNamer) Name() string { return p.req.PathValue("rd") } + +// A name returned through an interface method. +func viaInterface(n namer, snap apiv1.Snapshot) bool { + return n.Name() == snap.ResourceName // want +} + +var _ namer = pathNamer{} + +// A prefix test between two names. +func prefixed(req *http.Request, r apiv1.Resource) bool { + return strings.HasPrefix(r.NodeName, req.PathValue("node")) // want +} + +// A map literal keyed by a raw name. +func literalKey(req *http.Request) map[string]bool { + return map[string]bool{req.PathValue("node"): true} // want +} + +// More shapes. + +// A name appended to a list and searched for. +func appended(req *http.Request, r apiv1.Resource) bool { + var list []string + + list = append(list, req.PathValue("node")) + + return slices.Contains(list, r.NodeName) // want +} + +// A name handed to the function a slices.*Func call runs. +func viaContainsFunc(snap apiv1.Snapshot, req *http.Request) bool { + want := req.PathValue("node") + + return slices.ContainsFunc(snap.Nodes, func(n string) bool { + return n == want // want + }) +} + +// A helper that returns what it is given, called with a name. +func echo(s string) string { return strings.TrimSpace(s) } + +func viaEcho(req *http.Request, r apiv1.Resource) bool { + return echo(req.PathValue("node")) == r.NodeName // want +} + +// A closure called in place. +func inPlace(req *http.Request, r apiv1.Resource) bool { + return func() string { return req.PathValue("node") }() == r.NodeName // want +} + +// A name joined into a key. +func joinedKey(snap apiv1.Snapshot, byNodes map[string]int) int { + return byNodes[strings.Join(snap.Nodes, ",")] // want +} + +// A name stored as a map key and ranged back out. +func viaMapKeys(req *http.Request, r apiv1.Resource) bool { + props := map[string]string{} + props["Paths/"+naming.Key("x")] = "a" + props[req.PathValue("node")] = "b" // want + + for k := range props { + head, _, _ := strings.Cut(k, "/") + if head == r.NodeName { // want + return true + } + } + + return false +} + +// A map entry deleted by a raw name. +func deleted(req *http.Request, byNode map[string]int) { + delete(byNode, req.PathValue("node")) // want +} + +// A helper that returns a key, compared with a raw name. +func keyOf(s string) string { return naming.Key(s) } + +func viaKeyHelper(req *http.Request, r apiv1.Resource) bool { + return keyOf(r.NodeName) == req.PathValue("node") // want +} + +// A CR's name through its accessor, a key, against a raw name. +func viaGetName(req *http.Request, obj *crd.ResourceDefinition) bool { + return obj.GetName() == req.PathValue("rd") // want +} + +// Half a comparison normalized: a raw name against a key. +func halfNormalized(snap apiv1.Snapshot, r apiv1.Resource) bool { + for _, n := range snap.Nodes { + if n == naming.Key(r.NodeName) { // want + return true + } + } + + return false +} + +// Shapes it leaves alone. +func fine(req *http.Request, r apiv1.Resource, dev apiv1.PhysicalDevice, byKey map[string]int) bool { + a := req.PathValue("node") + b := naming.Key(r.NodeName) + + return naming.Same(a, r.NodeName) || + naming.Key(a) == b || + byKey[naming.Key(a)] > 0 || + a == "" || + dev.DevicePath == a || + req.PathValue("key") == r.NodeName +} + +// One field off two elements of one listing, by index and in a sort's +// comparator, is one source. +func oneListing(rows []apiv1.Resource) bool { + slices.SortFunc(rows, func(x, y apiv1.Resource) int { + if x.NodeName != y.NodeName { + return strings.Compare(x.NodeName, y.NodeName) + } + + return 0 + }) + + return len(rows) > 1 && rows[0].NodeName == rows[1].NodeName +} + +// Keys built from keys, constant keys and prefixes, a switch over +// constants, and a helper given no name return nothing raw. +func keyedShapes(req *http.Request, r apiv1.Resource, byPair map[string]int) bool { + k := byPair[naming.Key(r.NodeName)+"/"+naming.Key(r.Name)] + + byPair[fmt.Sprintf("%s/%s", naming.Key(r.NodeName), "pool")] + + switch req.PathValue("node") { + case "", "all": + return false + } + + m := map[string]bool{"fixed": true, naming.Key(r.NodeName): true} + delete(m, naming.Key(req.PathValue("node"))) + delete(m, "fixed") + + return k > 0 && m["fixed"] && strings.HasPrefix(r.NodeName, "pvc-") && echo("lit") == r.NodeName && + keyOf(r.NodeName) == naming.Key(req.PathValue("node")) +} diff --git a/pkg/placer/concurrent_place_test.go b/pkg/placer/concurrent_place_test.go new file mode 100644 index 00000000..663e5913 --- /dev/null +++ b/pkg/placer/concurrent_place_test.go @@ -0,0 +1,149 @@ +// 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 placer_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/placer" + "github.com/cozystack/blockstor/pkg/store" +) + +// laggingStore stands for the Kubernetes store behind an informer cache +// that has not seen any replica yet: ListByDefinition answers from the +// cache, ListByDefinitionUncached from the API server. +type laggingStore struct { + store.Store + + replicas *laggingReplicas +} + +func (s laggingStore) Resources() store.ResourceStore { return s.replicas } + +type laggingReplicas struct { + store.ResourceStore + + // readers and together hold a reader of the API server, after it has + // read, until a second one has read too, or a moment passes: two + // placements that may run at once both read before either writes. + readers atomic.Int32 + together chan struct{} +} + +func (r *laggingReplicas) ListByDefinition(context.Context, string) ([]apiv1.Resource, error) { + return nil, nil +} + +func (r *laggingReplicas) ListByDefinitionUncached(ctx context.Context, rdName string) ([]apiv1.Resource, error) { + seen, err := r.ResourceStore.ListByDefinition(ctx, rdName) + + if r.readers.Add(1) == 2 { + close(r.together) + } + + select { + case <-r.together: + case <-time.After(300 * time.Millisecond): + } + + return seen, err //nolint:wrapcheck // a test double passing the store's answer through +} + +func newLaggingStore(t *testing.T) (store.Store, store.Store) { + t.Helper() + + inner := store.NewInMemory() + seedStore(t, inner, []string{"n1", "n2", "n3"}) + + return inner, laggingStore{Store: inner, replicas: &laggingReplicas{ + ResourceStore: inner.Resources(), together: make(chan struct{}), + }} +} + +func replicaCount(t *testing.T, st store.Store) int { + t.Helper() + + got, err := st.Resources().ListByDefinition(t.Context(), "pvc-1") + if err != nil { + t.Fatalf("list: %v", err) + } + + return len(got) +} + +// The REST autoplace and a resource-group pass place one definition at +// once, with different filters. Each must see the other's replicas, or +// the definition ends up with more than it asked for. +func TestConcurrentPlacementsOfOneDefinitionDoNotOverplace(t *testing.T) { + t.Parallel() + + for round := range 3 { + inner, lagging := newLaggingStore(t) + + filters := []*apiv1.AutoSelectFilter{ + {PlaceCount: 2, NodeNameList: []string{"n1", "n2"}}, + {PlaceCount: 2, NodeNameList: []string{"n3"}}, + } + + var wg sync.WaitGroup + + for _, filter := range filters { + wg.Go(func() { + // One of the two comes up short on its node list by design. + if _, _, err := placer.New(lagging).Place(t.Context(), "pvc-1", filter); err != nil { + t.Logf("placement: %v", err) + } + }) + } + + wg.Wait() + + if got := replicaCount(t, inner); got != 2 { + t.Fatalf("round %d: replicas after two placements of place_count=2: %d, want 2", round, got) + } + } +} + +// A placement right after another counts the replicas the first made, +// though the cache has not caught up with them yet. +func TestAPlacementCountsReplicasTheCacheHasNotSeen(t *testing.T) { + t.Parallel() + + inner, lagging := newLaggingStore(t) + + if _, _, err := placer.New(lagging).Place(t.Context(), "pvc-1", + &apiv1.AutoSelectFilter{PlaceCount: 2, NodeNameList: []string{"n1", "n2"}}); err != nil { + t.Fatalf("first placement: %v", err) + } + + // Nothing is left to place; a shortfall error from the full node list + // is no concern here, only the count. + if _, _, err := placer.New(lagging).Place(t.Context(), "pvc-1", &apiv1.AutoSelectFilter{PlaceCount: 2}); err != nil { + t.Logf("second placement: %v", err) + } + + if got := replicaCount(t, inner); got != 2 { + t.Errorf("replicas after a second placement of place_count=2: %d, want 2", got) + } +} diff --git a/pkg/placer/name_key_internal_test.go b/pkg/placer/name_key_internal_test.go new file mode 100644 index 00000000..86b5c12f --- /dev/null +++ b/pkg/placer/name_key_internal_test.go @@ -0,0 +1,82 @@ +// 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 placer + +import ( + "context" + "testing" + + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store" +) + +// The min-rsc-count weight reads a pool's node in the spelling the pool was +// created with, NODE-1, and counts the replicas the store shows on Node-1: +// they are one node, so the loaded node scores lower than an empty one. +func TestMinRscCountCountsReplicasInAnySpellingOfThePoolsNode(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewInMemory() + + for _, rd := range []string{"pvc-a", "pvc-b"} { + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: rd}); err != nil { + t.Fatalf("seed RD: %v", err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: rd, NodeName: "Node-1"}); err != nil { + t.Fatalf("seed replica: %v", err) + } + } + + perNode, err := New(st).resourcesPerNode(ctx) + if err != nil { + t.Fatalf("resourcesPerNode: %v", err) + } + + w := weights{minRscCount: 1} + loaded := composite(&apiv1.StoragePool{StoragePoolName: "pool", NodeName: "NODE-1"}, w, perNode, 0) + empty := composite(&apiv1.StoragePool{StoragePoolName: "pool", NodeName: "node-2"}, w, perNode, 0) + + if loaded >= empty { + t.Errorf("NODE-1 with two replicas scores %v, an empty node %v; want the loaded node lower", loaded, empty) + } +} + +// An existing replica shows its node as Node-1 while the pool listing has +// it as node-1: the replica's pool is still found, so its shared LUN keeps a +// new replica off the pool that shares it. +func TestAnExistingReplicasPoolIsFoundInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + existing := []apiv1.Resource{{ + Name: "pvc-x", NodeName: "Node-1", Props: map[string]string{storPoolNameProp: "san"}, + }} + pools := []apiv1.StoragePool{ + {StoragePoolName: "san", NodeName: "node-1", ProviderKind: "LVM", SharedSpaceID: "lun-7"}, + {StoragePoolName: "san", NodeName: "node-2", ProviderKind: "LVM", SharedSpaceID: "lun-7"}, + } + + s := newState(&apiv1.AutoSelectFilter{}, existing, map[string]map[string]string{}, pools, false) + + if _, seen := s.sharedSeen["lun-7"]; !seen || s.firstKind != "LVM" { + t.Errorf("shared LUNs seen %v, first kind %q; want lun-7 and LVM from the replica on Node-1", + s.sharedSeen, s.firstKind) + } +} diff --git a/pkg/placer/placer.go b/pkg/placer/placer.go index 8e117509..b77fb4c3 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -31,10 +31,12 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -138,6 +140,23 @@ type Placer struct { store store.Store } +// placing serializes placement per definition across every Placer in the +// process. The REST autoplace, the resource-group reconcilers and node +// replacement all place, each counting the replicas there are and adding +// the rest; two at once each count the other's replicas as missing and +// the definition ends up with more than it asked for. +// +//nolint:gochecknoglobals // one lock set for every Placer in the process +var placing sync.Map + +func lockDefinition(rdName string) func() { + mu, _ := placing.LoadOrStore(naming.Key(rdName), &sync.Mutex{}) + lock, _ := mu.(*sync.Mutex) + lock.Lock() + + return lock.Unlock +} + // New constructs a Placer over the given store. The store reference // is stashed; callers that share state across goroutines should pass // the same store.Store the rest of the controller uses. @@ -159,6 +178,8 @@ func New(st store.Store) *Placer { // the migration semantic: even if 2 replicas exist, if one is on an // evicted node the placer will create a third on a healthy peer. func (p *Placer) Place(ctx context.Context, rdName string, filter *apiv1.AutoSelectFilter) (int, int, error) { + defer lockDefinition(rdName)() + // Bug 038: an RD born from a snapshot restore / clone must never // gain a diskful replica on a foreign backend or a snapshot-less // node — enforce the restore-source constraints HERE so every @@ -303,7 +324,7 @@ func (p *Placer) buildPlan(ctx context.Context, rdName string, filter *apiv1.Aut // assemblePlan finishes off buildPlan once the capacity gate has // passed. Split out so buildPlan stays under the gocyclo budget. func (p *Placer) assemblePlan(ctx context.Context, rdName string, filter *apiv1.AutoSelectFilter, candidates []apiv1.StoragePool, requiredKib int64, gates candidateGates) (*placePlan, error) { - existing, err := p.store.Resources().ListByDefinition(ctx, rdName) + existing, err := store.LiveReplicas(ctx, p.store, rdName) if err != nil { return nil, errors.Wrap(err, "list resources by definition") } @@ -402,7 +423,7 @@ func countDiskfulReplicas(existing []apiv1.Resource, disabled map[string]struct{ count := 0 for i := range existing { - if _, off := disabled[existing[i].NodeName]; off { + if _, off := disabled[naming.Key(existing[i].NodeName)]; off { continue } @@ -452,7 +473,7 @@ func (p *Placer) placeDiskful(ctx context.Context, rdName string, state *state, // met. func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, state *state) error { for nodeName, hostProps := range state.nodes { - if _, busy := state.taken[nodeName]; busy { + if _, busy := state.taken[naming.Key(nodeName)]; busy { continue } @@ -469,7 +490,7 @@ func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, st return errors.Wrap(err, "create diskless witness") } - state.taken[nodeName] = struct{}{} + state.taken[naming.Key(nodeName)] = struct{}{} } return nil @@ -541,6 +562,8 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m } for i := range existing { + replica := &existing[i] + // corner-D2b: a node whose only replica is a diskless witness // (DISKLESS, typically also TIE_BREAKER) is NOT taken for // diskful placement — it's an UPGRADE candidate. Record it so @@ -548,13 +571,13 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // NOT add it to `taken` (which would make candidateNodeProps // reject it the way a real diskful peer is rejected). Diskful // replicas still claim the node via `taken` below. - if slices.Contains(existing[i].Flags, apiv1.ResourceFlagDiskless) { - s.upgradeable[existing[i].NodeName] = existing[i].Props[storPoolNameProp] + if slices.Contains(replica.Flags, apiv1.ResourceFlagDiskless) { + s.upgradeable[naming.Key(replica.NodeName)] = replica.Props[storPoolNameProp] continue } - s.taken[existing[i].NodeName] = struct{}{} + s.taken[naming.Key(replica.NodeName)] = struct{}{} // Seed XReplicasOnDifferentMap buckets from this diskful // replica. DISKLESS witnesses (handled by the continue above) @@ -562,7 +585,7 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // attachments and upstream LINSTOR doesn't count them toward // x-replicas either (mirrors place_count's diskful-only // semantic). - nodeProps := nodes[existing[i].NodeName] + nodeProps := nodes[naming.Key(replica.NodeName)] for key := range filter.XReplicasOnDifferentMap { s.xBuckets[xBucketKey(key, nodeProps)]++ } @@ -570,12 +593,12 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // Pre-seed shared-space anti-affinity: if an existing replica // already lives on a shared-LUN pool, no new replica may land // on a pool sharing that LUN identifier. - stor := existing[i].Props[storPoolNameProp] + stor := replica.Props[storPoolNameProp] if stor == "" { continue } - pool, ok := s.pools[poolKey(existing[i].NodeName, stor)] + pool, ok := s.pools[poolKey(replica.NodeName, stor)] if !ok { continue } @@ -606,11 +629,11 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // Returns (nil, false) on rejection so the caller stays a single // if-not-ok-return. func (s *state) candidateNodeProps(pool *apiv1.StoragePool) (map[string]string, bool) { - if _, busy := s.taken[pool.NodeName]; busy { + if _, busy := s.taken[naming.Key(pool.NodeName)]; busy { return nil, false } - nodeProps := s.nodes[pool.NodeName] + nodeProps := s.nodes[naming.Key(pool.NodeName)] if !matchesTuple(nodeProps, s.filter.ReplicasOnSame, s.sameTuple) { return nil, false @@ -716,7 +739,7 @@ func (s *state) tryPlace(ctx context.Context, st store.Store, rdName string, poo // the chosen StorPoolName, the exact transition the explicit // `r c --storage-pool` toggle-disk path performs via // pkg/rest.promoteDisklessReplica. - if _, isWitness := s.upgradeable[pool.NodeName]; isWitness { + if _, isWitness := s.upgradeable[naming.Key(pool.NodeName)]; isWitness { err := s.promoteWitness(ctx, st, rdName, pool) if err != nil { return false, err @@ -734,8 +757,8 @@ func (s *state) tryPlace(ctx context.Context, st store.Store, rdName string, poo } } - s.taken[pool.NodeName] = struct{}{} - delete(s.upgradeable, pool.NodeName) + s.taken[naming.Key(pool.NodeName)] = struct{}{} + delete(s.upgradeable, naming.Key(pool.NodeName)) if s.sameTuple == nil && len(s.filter.ReplicasOnSame) > 0 { s.sameTuple = lookupKeys(nodeProps, s.filter.ReplicasOnSame) @@ -825,9 +848,11 @@ func (s *state) promoteWitness(ctx context.Context, st store.Store, rdName strin } // poolKey is the composite (node, pool) lookup key used to find a -// StoragePool from a Resource's StorPoolName + NodeName pair. +// StoragePool from a Resource's StorPoolName + NodeName pair. The +// replica and the pool listing each spell the node their own way, so +// both halves are keys. func poolKey(node, pool string) string { - return node + "\x1f" + pool + return naming.Key(node) + "\x1f" + naming.Key(pool) } func poolsByKey(pools []apiv1.StoragePool) map[string]apiv1.StoragePool { @@ -874,18 +899,18 @@ func (p *Placer) applyNotPlaceWith(ctx context.Context, rdName string, filter *a for i := range all { // Skip resources of the RD being placed — otherwise the first // replica we land locks every subsequent placement out. - if all[i].Name == rdName { + if naming.Same(all[i].Name, rdName) { continue } if slices.Contains(filter.NotPlaceWithRsc, all[i].Name) { - excluded[all[i].NodeName] = struct{}{} + excluded[naming.Key(all[i].NodeName)] = struct{}{} continue } if pattern != nil && pattern.MatchString(all[i].Name) { - excluded[all[i].NodeName] = struct{}{} + excluded[naming.Key(all[i].NodeName)] = struct{}{} } } @@ -896,7 +921,7 @@ func (p *Placer) applyNotPlaceWith(ctx context.Context, rdName string, filter *a out := candidates[:0:0] for i := range candidates { - if _, blocked := excluded[candidates[i].NodeName]; blocked { + if _, blocked := excluded[naming.Key(candidates[i].NodeName)]; blocked { continue } @@ -1138,7 +1163,7 @@ func (p *Placer) resourcesPerNode(ctx context.Context) (map[string]int, error) { out := make(map[string]int, len(all)) for i := range all { - out[all[i].NodeName]++ + out[naming.Key(all[i].NodeName)]++ } return out, nil @@ -1200,7 +1225,7 @@ func composite(pool *apiv1.StoragePool, w weights, rscPerNode map[string]int, ma } if w.minRscCount > 0 { - score += w.minRscCount * (1.0 / float64(1+rscPerNode[pool.NodeName])) + score += w.minRscCount * (1.0 / float64(1+rscPerNode[naming.Key(pool.NodeName)])) } if w.maxThroughput > 0 && maxThroughput > 0 { @@ -1267,7 +1292,7 @@ func matchesPoolFilter(pool *apiv1.StoragePool, filter *apiv1.AutoSelectFilter, return false } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { return false } @@ -1280,15 +1305,15 @@ func matchesPoolFilter(pool *apiv1.StoragePool, filter *apiv1.AutoSelectFilter, return false } - if filter.StoragePool != "" && pool.StoragePoolName != filter.StoragePool { + if filter.StoragePool != "" && !naming.Same(pool.StoragePoolName, filter.StoragePool) { return false } - if len(filter.StoragePoolList) > 0 && !slices.Contains(filter.StoragePoolList, pool.StoragePoolName) { + if len(filter.StoragePoolList) > 0 && !slices.ContainsFunc(filter.StoragePoolList, func(n string) bool { return naming.Same(n, pool.StoragePoolName) }) { return false } - if len(filter.NodeNameList) > 0 && !slices.Contains(filter.NodeNameList, pool.NodeName) { + if len(filter.NodeNameList) > 0 && !slices.ContainsFunc(filter.NodeNameList, func(n string) bool { return naming.Key(n) == naming.Key(pool.NodeName) }) { return false } @@ -1343,7 +1368,7 @@ func (p *Placer) disabledNodes(ctx context.Context) (map[string]struct{}, error) for i := range nodes { for _, f := range nodes[i].Flags { if f == apiv1.NodeFlagEvicted || f == apiv1.NodeFlagLost { - out[nodes[i].Name] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} break } @@ -1386,7 +1411,7 @@ func (p *Placer) autoplaceExcludedNodes(ctx context.Context, disabled map[string v, perr := strconv.ParseBool(raw) if perr == nil && !v { - out[nodes[i].Name] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} } } @@ -1401,7 +1426,7 @@ func (p *Placer) nodesByName(ctx context.Context) (map[string]map[string]string, out := make(map[string]map[string]string, len(list)) for i := range list { - out[list[i].Name] = list[i].Props + out[naming.Key(list[i].Name)] = list[i].Props } return out, nil @@ -1414,7 +1439,7 @@ func topologyTuple(existing []apiv1.Resource, nodes map[string]map[string]string return nil } - return lookupKeys(nodes[existing[0].NodeName], keys) + return lookupKeys(nodes[naming.Key(existing[0].NodeName)], keys) } func topologySeen(existing []apiv1.Resource, nodes map[string]map[string]string, keys []string) map[string]struct{} { @@ -1431,7 +1456,7 @@ func topologySeen(existing []apiv1.Resource, nodes map[string]map[string]string, k := spec for i := range existing { - value := nodes[existing[i].NodeName][auxKey(k)] + value := nodes[naming.Key(existing[i].NodeName)][auxKey(k)] out[k+"="+value] = struct{}{} } } @@ -1569,7 +1594,7 @@ func partitionByExclusion(candidates []apiv1.StoragePool, nodes map[string]map[s for i := range candidates { pool := candidates[i] - nodeProps := nodes[pool.NodeName] + nodeProps := nodes[naming.Key(pool.NodeName)] if matchesAnyExcludedPair(nodeProps, keys) { lastResort = append(lastResort, pool) @@ -1626,7 +1651,7 @@ func filterSamePinned(candidates []apiv1.StoragePool, nodes map[string]map[strin out := candidates[:0:0] for i := range candidates { - nodeProps := nodes[candidates[i].NodeName] + nodeProps := nodes[naming.Key(candidates[i].NodeName)] if matchesAllSamePins(nodeProps, keys) { out = append(out, candidates[i]) @@ -1679,7 +1704,7 @@ func pickSameGroup(candidates []apiv1.StoragePool, nodes map[string]map[string]s for i := range candidates { pool := candidates[i] - tuple := lookupKeys(nodes[pool.NodeName], keys) + tuple := lookupKeys(nodes[naming.Key(pool.NodeName)], keys) key := tupleKey(tuple) grp, ok := byKey[key] @@ -1689,7 +1714,7 @@ func pickSameGroup(candidates []apiv1.StoragePool, nodes map[string]map[string]s } grp.pools = append(grp.pools, pool) - grp.seen[pool.NodeName] = struct{}{} + grp.seen[naming.Key(pool.NodeName)] = struct{}{} grp.free += pool.FreeCapacity } diff --git a/pkg/placer/placer_test.go b/pkg/placer/placer_test.go index 740888a9..cc078be6 100644 --- a/pkg/placer/placer_test.go +++ b/pkg/placer/placer_test.go @@ -3634,3 +3634,173 @@ func TestAutoPlaceMatchesProviderKindWithStorPoolName(t *testing.T) { // Keep go-vet happy on unused symbols in the import set. var _ = context.Background + +// A replica shows its node as it was registered, N1, while the node's pool +// keeps the spelling it was created with, n1. The placer reads the two as +// one node and does not place a second replica on it, though n1 has the +// biggest pool. +func TestPlaceRespectsAnExistingReplicaInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + seedStore(t, st, []string{"n3", "n2", "n1"}) + + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-1", NodeName: "N1", + Props: map[string]string{"StorPoolName": "pool"}, + }); err != nil { + t.Fatalf("seed existing: %v", err) + } + + if _, _, err := placer.New(st).Place(ctx, "pvc-1", &apiv1.AutoSelectFilter{PlaceCount: 2}); err != nil { + t.Fatalf("Place: %v", err) + } + + got, _ := st.Resources().ListByDefinition(ctx, "pvc-1") + for i := range got { + if got[i].NodeName == "n1" { + t.Fatalf("placed a second replica on n1 next to the one on N1: %+v", got) + } + } + + if len(got) != 2 { + t.Errorf("total: got %d, want 2; %+v", len(got), got) + } +} + +// seedMixedSpellings registers nodes N1.. in upper case, with props, and +// creates their pools in the spelling poolNode gives, which a pool keeps +// from its create. +func seedMixedSpellings(t *testing.T, st store.Store, flags map[string][]string, props map[string]map[string]string, poolNode func(string) string) { + t.Helper() + + ctx := t.Context() + + for i, name := range []string{"N1", "N2", "N3"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{ + Name: name, Type: apiv1.NodeTypeSatellite, Flags: flags[name], Props: props[name], + }); err != nil { + t.Fatalf("seed node %s: %v", name, err) + } + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + NodeName: poolNode(name), + StoragePoolName: "pool", + ProviderKind: apiv1.StoragePoolKindLVMThin, + FreeCapacity: int64(i+1) * 100, + TotalCapacity: 1000, + }); err != nil { + t.Fatalf("seed pool on %s: %v", name, err) + } + } +} + +// The node sets and maps the placer reads by a pool's node find the node +// registered as N3 for a pool created on n3: an EVICTED node takes no new +// replica, a node list names it, and its Aux props spread the replicas. +func TestPlacerReadsAPoolOnAnotherSpellingOfItsNode(t *testing.T) { + t.Parallel() + + zones := map[string]map[string]string{ + "N1": {"Aux/zone": "a", "Aux/rack": "x"}, + "N2": {"Aux/zone": "b", "Aux/rack": "x"}, + "N3": {"Aux/zone": "c", "Aux/rack": "y"}, + } + + for name, tc := range map[string]struct { + flags map[string][]string + filter apiv1.AutoSelectFilter + check func(t *testing.T, got []apiv1.Resource) + }{ + "an EVICTED node": { + flags: map[string][]string{"N3": {apiv1.NodeFlagEvicted}}, + filter: apiv1.AutoSelectFilter{PlaceCount: 1}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + if len(got) != 1 || strings.EqualFold(got[0].NodeName, "n3") { + t.Errorf("placed %+v, want one replica off the EVICTED N3", got) + } + }, + }, + "a node list": { + filter: apiv1.AutoSelectFilter{PlaceCount: 1, NodeNameList: []string{"N3"}}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + if len(got) != 1 || !strings.EqualFold(got[0].NodeName, "n3") { + t.Errorf("placed %+v, want one replica on N3", got) + } + }, + }, + "replicas on the same zone": { + filter: apiv1.AutoSelectFilter{PlaceCount: 2, ReplicasOnSame: []string{"Aux/rack"}}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + // N3 is alone on rack y, so two replicas on one rack are N1 and N2. + if len(got) != 2 || slices.ContainsFunc(got, func(r apiv1.Resource) bool { return strings.EqualFold(r.NodeName, "n3") }) { + t.Errorf("placed %+v, want two replicas on rack x, N1 and N2", got) + } + }, + }, + "a pinned rack": { + filter: apiv1.AutoSelectFilter{PlaceCount: 1, ReplicasOnSame: []string{"Aux/rack=y"}}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + if len(got) != 1 || !strings.EqualFold(got[0].NodeName, "n3") { + t.Errorf("placed %+v, want one replica on rack y, N3", got) + } + }, + }, + "a zone to stay off": { + filter: apiv1.AutoSelectFilter{PlaceCount: 1, ReplicasOnDifferent: []string{"Aux/zone=c"}}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + // N3, zone c, has the biggest pool; the soft exclusion moves + // the replica off it. + if len(got) != 1 || strings.EqualFold(got[0].NodeName, "n3") { + t.Errorf("placed %+v, want one replica off zone c", got) + } + }, + }, + "replicas on different zones": { + filter: apiv1.AutoSelectFilter{PlaceCount: 2, ReplicasOnDifferent: []string{"Aux/zone"}}, + check: func(t *testing.T, got []apiv1.Resource) { + t.Helper() + + if len(got) != 2 { + t.Errorf("placed %d replicas, want 2 in different zones: %+v", len(got), got) + } + }, + }, + } { + for spelling, poolNode := range map[string]func(string) string{ + "pool on the CR name": strings.ToLower, + "pool on the registered spelling": func(n string) string { return n }, + } { + t.Run(name+"/"+spelling, func(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + seedMixedSpellings(t, st, tc.flags, zones, poolNode) + + filter := tc.filter + _, _, _ = placer.New(st).Place(ctx, "pvc-1", &filter) + + got, err := st.Resources().ListByDefinition(ctx, "pvc-1") + if err != nil { + t.Fatalf("list: %v", err) + } + + tc.check(t, got) + }) + } + } +} diff --git a/pkg/rest/advise.go b/pkg/rest/advise.go index 790a3aa9..fbd58d53 100644 --- a/pkg/rest/advise.go +++ b/pkg/rest/advise.go @@ -23,6 +23,7 @@ import ( "net/http" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // registerAdvise wires `linstor advise resource` and the cluster-wide @@ -146,11 +147,11 @@ func pickAdvicePools(pools []apiv1.StoragePool, filter *apiv1.AutoSelectFilter, continue } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { continue } - if filter.StoragePool != "" && pool.StoragePoolName != filter.StoragePool { + if filter.StoragePool != "" && !naming.Same(pool.StoragePoolName, filter.StoragePool) { continue } diff --git a/pkg/rest/autoplace.go b/pkg/rest/autoplace.go index 0830c5ce..c28ac53f 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -34,6 +34,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" ) @@ -542,7 +543,7 @@ func (s *Server) persistAutoplaceLayerList(w http.ResponseWriter, r *http.Reques return true } - rd.LayerStack = append([]string(nil), layerList...) + rd.LayerStack = storedLayerStack(layerList) // Bug 205: typed-Patch via PatchResourceDefinitionSpec — the // closure mutates a freshly-fetched live RD on every retry, so @@ -557,7 +558,7 @@ func (s *Server) persistAutoplaceLayerList(w http.ResponseWriter, r *http.Reques return nil } - live.LayerStack = append([]string(nil), layerList...) + live.LayerStack = storedLayerStack(layerList) return nil }) @@ -643,7 +644,7 @@ func (s *Server) resolveAdditionalPlaceCount(ctx context.Context, rdName string, diskful := 0 for i := range existing { - if _, off := disabled[existing[i].NodeName]; off { + if _, off := disabled[naming.Key(existing[i].NodeName)]; off { continue } @@ -1434,7 +1435,7 @@ func (s *Server) handleResourceCreateOnNode(w http.ResponseWriter, r *http.Reque switch { case env.Resource.NodeName == "": env.Resource.NodeName = node - case env.Resource.NodeName != node: + case !naming.Same(env.Resource.NodeName, node): writeError(w, http.StatusBadRequest, "single-node resource create: body node '"+env.Resource.NodeName+ "' does not match URL path node '"+node+"'") @@ -1540,7 +1541,7 @@ func (s *Server) createOneResource(w http.ResponseWriter, r *http.Request, rdNam _ = s.Store.ResourceDefinitions().PatchResourceDefinitionSpec(r.Context(), rdName, func(rd *apiv1.ResourceDefinition) error { if len(rd.LayerStack) == 0 { - rd.LayerStack = append([]string(nil), env.LayerList...) + rd.LayerStack = storedLayerStack(env.LayerList) } return nil @@ -2041,7 +2042,7 @@ func (s *Server) resolveTakeoverStorPool(ctx context.Context, rdName, takeoverNo siblings, listErr := s.Store.Resources().ListByDefinition(ctx, rdName) if listErr == nil { for i := range siblings { - if siblings[i].NodeName == takeoverNode { + if naming.Same(siblings[i].NodeName, takeoverNode) { continue } @@ -2507,7 +2508,7 @@ func (s *Server) handleResourceMakeAvailable(w http.ResponseWriter, r *http.Requ err = s.Store.ResourceDefinitions().PatchResourceDefinitionSpec(r.Context(), rdName, func(live *apiv1.ResourceDefinition) error { if len(live.LayerStack) == 0 { - live.LayerStack = append([]string(nil), req.LayerList...) + live.LayerStack = storedLayerStack(req.LayerList) } return nil @@ -2914,7 +2915,7 @@ func (s *Server) bumpPeerChangedOnSiblings(ctx context.Context, rdName, removedN for i := range siblings { sib := &siblings[i] - if sib.NodeName == removedNode { + if naming.Same(sib.NodeName, removedNode) { // Defensive: the deleted Resource should already be // absent from the list, but a racing Create could // surface a fresh replica on the same node — never diff --git a/pkg/rest/error_reports.go b/pkg/rest/error_reports.go index 712852f1..76c11faa 100644 --- a/pkg/rest/error_reports.go +++ b/pkg/rest/error_reports.go @@ -26,6 +26,8 @@ import ( "strings" "sync" "time" + + "github.com/cozystack/blockstor/pkg/naming" ) // errorReportRingCap caps the in-memory buffer at 1000 entries — the @@ -186,7 +188,7 @@ func (s *Server) registerErrorReports(mux *http.ServeMux) { // Query parameters honoured (upstream `Controller.GetErrorReports` // passes the same names): // -// - `?node=NAME` filters by satellite name (case-insensitive). Repeated +// - `?node=NAME` filters by satellite name (naming.Same). Repeated // values aren't supported; the operator hits the endpoint per node. // - `?since=...` returns only entries with `error_time >= since`. We // accept either RFC3339 ("2026-05-14T10:00:00Z") — the form the @@ -219,7 +221,7 @@ func (s *Server) handleErrorReportsList(w http.ResponseWriter, r *http.Request) filtered := entries[:0] for i := range entries { - if strings.EqualFold(entries[i].NodeName, node) { + if naming.Same(entries[i].NodeName, node) { filtered = append(filtered, entries[i]) } } diff --git a/pkg/rest/input_validation_bug_94_97_test.go b/pkg/rest/input_validation_bug_94_97_test.go index ff07257d..c58f30b4 100644 --- a/pkg/rest/input_validation_bug_94_97_test.go +++ b/pkg/rest/input_validation_bug_94_97_test.go @@ -40,7 +40,7 @@ import ( // // Bug 97: `linstor rd c " "` leaks the raw k8s "metadata.name is // invalid: -..." apimachinery error to the operator, exposing -// pkg/store/k8s.Name()'s internal hash-prefix scheme. Expected: 400 + +// pkg/naming.Key()'s internal hash-prefix scheme. Expected: 400 + // LINSTOR envelope naming the offending input and rule, no CRD // persisted, no hash-prefix in the message. diff --git a/pkg/rest/layer_stack_case_test.go b/pkg/rest/layer_stack_case_test.go new file mode 100644 index 00000000..4b26fc6d --- /dev/null +++ b/pkg/rest/layer_stack_case_test.go @@ -0,0 +1,227 @@ +// 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 rest + +import ( + "encoding/json" + "io" + "net/http" + "slices" + "testing" + + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store" +) + +// A stack is validated case-insensitively but some readers compare the +// stored value exactly (the satellite controller's DRBD check among them), +// so every write path stores the canonical spelling. +func wantCanonicalStack() []string { return []string{apiv1.LayerKindDRBD, apiv1.LayerKindStorage} } + +func TestRDCreateStoresLayerStackInCanonicalCase(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + base, stop := startServerWithStore(t, st) + defer stop() + + postOK(t, base+"/v1/resource-definitions", map[string]any{ + "resource_definition": map[string]any{"name": "rd-case"}, + "layer_list": []string{"drbd", "Storage"}, + }, http.StatusCreated) + + rd, err := st.ResourceDefinitions().Get(t.Context(), "rd-case") + if err != nil { + t.Fatalf("get: %v", err) + } + + if !slices.Equal(rd.LayerStack, wantCanonicalStack()) { + t.Fatalf("stored stack %v, want %v", rd.LayerStack, wantCanonicalStack()) + } +} + +func TestRGCreateAndModifyStoreLayerStackInCanonicalCase(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + base, stop := startServerWithStore(t, st) + defer stop() + + postOK(t, base+"/v1/resource-groups", map[string]any{ + "name": "rg-case", + "select_filter": map[string]any{"layer_stack": []string{"drbd", "storage"}}, + }, http.StatusCreated) + + rg, err := st.ResourceGroups().Get(t.Context(), "rg-case") + if err != nil { + t.Fatalf("get: %v", err) + } + + if !slices.Equal(rg.SelectFilter.LayerStack, wantCanonicalStack()) { + t.Fatalf("rg create stored %v, want %v", rg.SelectFilter.LayerStack, wantCanonicalStack()) + } + + raw, err := json.Marshal(map[string]any{ + "select_filter": map[string]any{"layer_stack": []string{"storage"}}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp := httpPut(t, base+"/v1/resource-groups/rg-case", raw) + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("rg modify: status %d: %s", resp.StatusCode, body) + } + + rg, err = st.ResourceGroups().Get(t.Context(), "rg-case") + if err != nil { + t.Fatalf("get: %v", err) + } + + if !slices.Equal(rg.SelectFilter.LayerStack, []string{apiv1.LayerKindStorage}) { + t.Fatalf("rg modify stored %v, want [STORAGE]", rg.SelectFilter.LayerStack) + } +} + +func postOK(t *testing.T, url string, body any, want int) { + t.Helper() + + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp := httpPost(t, url, raw) + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != want { + got, _ := io.ReadAll(resp.Body) + t.Fatalf("POST %s: status %d, want %d: %s", url, resp.StatusCode, want, got) + } +} + +// An autoplace that gives a definition its first stack stores it in the +// same canonical spelling as every other write path. +func TestStoredLayerStackIsCanonical(t *testing.T) { + t.Parallel() + + if got := storedLayerStack([]string{"drbd", "storage"}); !slices.Equal(got, wantCanonicalStack()) { + t.Fatalf("stored %v, want %v", got, wantCanonicalStack()) + } + + if got := storedLayerStack([]string{"bogus"}); !slices.Equal(got, []string{"bogus"}) { + t.Fatalf("an invalid stack was not kept as given: %v", got) + } +} + +// Each call that hands a definition its first stack through a CSI-style +// layer_list (autoplace, explicit resource create, make-available) stores +// that stack in canonical case, so a lowercase list reads back the way +// every other write path stores it. +func TestCSILayerListIsStoredInCanonicalCase(t *testing.T) { + t.Parallel() + + lower := []string{"drbd", "storage"} + + for _, tc := range []struct { + name string + path string + body any + want int + }{ + { + name: "autoplace", + path: "/v1/resource-definitions/rd-csi/autoplace", + body: apiv1.AutoPlaceRequest{ + SelectFilter: apiv1.AutoSelectFilter{PlaceCount: 1, StoragePool: "pool"}, + LayerList: lower, + }, + want: http.StatusOK, + }, + { + name: "resource create", + path: "/v1/resource-definitions/rd-csi/resources", + body: apiv1.ResourceCreate{ + Resource: apiv1.Resource{Name: "rd-csi", NodeName: "n1"}, + LayerList: lower, + }, + want: http.StatusCreated, + }, + { + name: "make-available", + path: "/v1/resource-definitions/rd-csi/resources/n1/make-available", + body: apiv1.ResourceMakeAvailable{LayerList: lower}, + want: http.StatusOK, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "rd-csi"}); err != nil { + t.Fatalf("seed rd: %v", err) + } + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "n1"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + StoragePoolName: "pool", NodeName: "n1", ProviderKind: apiv1.StoragePoolKindLVMThin, + }); err != nil { + t.Fatalf("seed pool: %v", err) + } + + base, stop := startServerWithStore(t, st) + defer stop() + + postOK(t, base+tc.path, tc.body, tc.want) + + rd, err := st.ResourceDefinitions().Get(ctx, "rd-csi") + if err != nil { + t.Fatalf("get: %v", err) + } + + if !slices.Equal(rd.LayerStack, wantCanonicalStack()) { + t.Fatalf("stored stack %v, want %v", rd.LayerStack, wantCanonicalStack()) + } + }) + } +} + +// A group stored with a lowercase stack before writes were canonicalised is +// not rebalanced when a modify resends the same stack in canonical case. +func TestRGNeedsRebalanceIgnoresTheStackCase(t *testing.T) { + t.Parallel() + + prev := &apiv1.AutoSelectFilter{LayerStack: []string{"drbd", "storage"}} + + if rgNeedsRebalance(prev, &apiv1.AutoSelectFilter{LayerStack: wantCanonicalStack()}) { + t.Error("the same stack in another case asked for a rebalance") + } + + if !rgNeedsRebalance(prev, &apiv1.AutoSelectFilter{LayerStack: []string{"DRBD", "LUKS", "STORAGE"}}) { + t.Error("a different stack asked for no rebalance") + } +} diff --git a/pkg/rest/layer_validation.go b/pkg/rest/layer_validation.go index fbd7117a..8891552c 100644 --- a/pkg/rest/layer_validation.go +++ b/pkg/rest/layer_validation.go @@ -55,3 +55,37 @@ var ( func validateLayerStack(layers []string) error { return validate.LayerStack(layers) } + +// canonicalizeLayerStack validates *layers like validateLayerStack and, when +// the stack is non-empty, replaces it with its canonical-case spelling, which +// is what the write paths store. Some readers compare the stored value +// exactly, so a lowercase stack stored verbatim read as "no DRBD" to them. +// +//nolint:wrapcheck // see validateLayerStack +func canonicalizeLayerStack(layers *[]string) error { + if len(*layers) == 0 { + return nil + } + + canonical, err := validate.CanonicalLayerStack(*layers) + if err != nil { + return err + } + + *layers = canonical + + return nil +} + +// storedLayerStack is the stack an autoplace stores on a definition that +// has none: the canonical spelling of a valid stack, so exact readers see +// the same value every other write path stores, or a copy of the input as +// given when it does not validate, which these paths never refused. +func storedLayerStack(layers []string) []string { + canonical, err := validate.CanonicalLayerStack(layers) + if err == nil && canonical != nil { + return canonical + } + + return append([]string(nil), layers...) +} diff --git a/pkg/rest/multivalue_query_test.go b/pkg/rest/multivalue_query_test.go index d1a83359..cc0b3632 100644 --- a/pkg/rest/multivalue_query_test.go +++ b/pkg/rest/multivalue_query_test.go @@ -158,9 +158,9 @@ func TestMatchAnyFold(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := matchAnyFold(tc.needles, tc.candidate) + got := matchAnyName(tc.needles, tc.candidate) if got != tc.want { - t.Errorf("matchAnyFold(%v, %q) = %v, want %v", + t.Errorf("matchAnyName(%v, %q) = %v, want %v", tc.needles, tc.candidate, got, tc.want) } }) diff --git a/pkg/rest/n_d_registered_spelling_race_test.go b/pkg/rest/n_d_registered_spelling_race_test.go index 734aa69f..791c0518 100644 --- a/pkg/rest/n_d_registered_spelling_race_test.go +++ b/pkg/rest/n_d_registered_spelling_race_test.go @@ -10,6 +10,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -53,7 +54,7 @@ func (f foldingNodes) registered(ctx context.Context, name string) (string, erro } for i := range all { - if store.FoldName(all[i].Name) == store.FoldName(name) { + if naming.Key(all[i].Name) == naming.Key(name) { return all[i].Name, nil } } diff --git a/pkg/rest/name_filter_internal_test.go b/pkg/rest/name_filter_internal_test.go new file mode 100644 index 00000000..f6a745cc --- /dev/null +++ b/pkg/rest/name_filter_internal_test.go @@ -0,0 +1,41 @@ +// 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 rest + +import "testing" + +// A list filter names objects the way the store addresses them: PVC-X finds +// pvc-x, and my_res does not find My_Res, a definition of its own. +func TestMatchAnyNameMatchesByKey(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + filter, value string + want bool + }{ + {"PVC-X", "pvc-x", true}, + {"my_res", "My_Res", false}, + {"My_Res", "My_Res", true}, + {"pvc-y", "pvc-x", false}, + } { + if got := matchAnyName([]string{tc.filter}, tc.value); got != tc.want { + t.Errorf("matchAnyName(%q, %q) = %v, want %v", tc.filter, tc.value, got, tc.want) + } + } +} diff --git a/pkg/rest/name_spelling_cr_test.go b/pkg/rest/name_spelling_cr_test.go new file mode 100644 index 00000000..e681e505 --- /dev/null +++ b/pkg/rest/name_spelling_cr_test.go @@ -0,0 +1,283 @@ +// 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 rest + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync/atomic" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +var errInjectedParentRead = errors.New("injected parent read failure") + +func crScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + return scheme +} + +// A definition delete under another spelling never passes its snapshot check +// on a short listing. Whichever read of the definition fails, the delete +// either refuses or deletes nothing that still has a snapshot. +func TestAFailedParentReadNeverLetsADeletePassItsSnapshots(t *testing.T) { + t.Parallel() + + for fail := int32(1); fail <= 8; fail++ { + rd := crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Pvc-Reg")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Reg") + + base := fake.NewClientBuilder().WithScheme(crScheme(t)).WithObjects(&rd, &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-reg.snap-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "Pvc-Reg", SnapshotName: "snap-1"}, + }).Build() + + var gets atomic.Int32 + + cli := interceptor.NewClient(base, interceptor.Funcs{ + Get: func(ctx context.Context, c ctrlclient.WithWatch, key ctrlclient.ObjectKey, obj ctrlclient.Object, opts ...ctrlclient.GetOption) error { + if _, ok := obj.(*crdv1alpha1.ResourceDefinition); ok && gets.Add(1) == fail { + return errInjectedParentRead + } + + return c.Get(ctx, key, obj, opts...) + }, + }) + + addr, stop := startServerWithStore(t, k8s.New(cli)) + resp := httpDelete(t, addr+"/v1/resource-definitions/PVC-REG") + _ = resp.Body.Close() + + stop() + + var snap crdv1alpha1.Snapshot + + snapErr := base.Get(t.Context(), types.NamespacedName{Name: "pvc-reg.snap-1"}, &snap) + + var left crdv1alpha1.ResourceDefinition + + rdErr := base.Get(t.Context(), types.NamespacedName{Name: rd.Name}, &left) + + if snapErr == nil && (rdErr != nil || left.DeletionTimestamp != nil) { + t.Errorf("read %d failed: DELETE answered %d and took the definition away from its snapshot", fail, resp.StatusCode) + } + + if resp.StatusCode == http.StatusOK && snapErr == nil { + t.Errorf("read %d failed: DELETE answered 200 over a definition with a snapshot", fail) + } + } +} + +// My_Node and my_node are outside RFC 1123: they hash to two CR names and are +// two nodes. A snapshot targeting both is not successful on one entry. +func TestSnapshotSuccessKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { + t.Parallel() + + diskful := map[string]struct{}{naming.Key("My_Node"): {}, naming.Key("my_node"): {}} + snap := &apiv1.Snapshot{ + Nodes: []string{"My_Node", "my_node"}, + Snapshots: []apiv1.SnapshotPerNode{{NodeName: "my_node", CreateTimestamp: 1}}, + } + + if isSnapshotSuccessful(snap, diskful) { + t.Error("a snapshot reported on my_node alone reads as successful on My_Node too") + } +} + +// An entry reported under the node's CR name matches the node as registered. +func TestSnapshotSuccessMatchesAnEntryUnderTheCRName(t *testing.T) { + t.Parallel() + + diskful := map[string]struct{}{naming.Key("My_Node"): {}} + snap := &apiv1.Snapshot{ + Nodes: []string{"My_Node"}, + Snapshots: []apiv1.SnapshotPerNode{{NodeName: naming.Key("My_Node"), CreateTimestamp: 1}}, + } + + if !isSnapshotSuccessful(snap, diskful) { + t.Error("an entry under My_Node's CR name does not count for My_Node") + } +} + +// A restore takes the pool of the source's replica on the node it lands on, +// and My_Node's replica is not my_node's. +func TestRestorePoolKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { + t.Parallel() + + cli := fake.NewClientBuilder().WithScheme(crScheme(t)).Build() + st := k8s.New(cli) + ctx := t.Context() + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-two"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + for node, pool := range map[string]string{"My_Node": "pool-upper", "my_node": "pool-lower"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: node}); err != nil { + t.Fatalf("seed node %s: %v", node, err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-two", NodeName: node, Props: map[string]string{"StorPoolName": pool}, + }); err != nil { + t.Fatalf("seed replica on %s: %v", node, err) + } + } + + byNode, _ := storPoolsByNodeFromSourceRD(ctx, st, "pvc-two") + if got := byNode[naming.Key("My_Node")]; got != "pool-upper" { + t.Errorf("pool for My_Node = %q, want pool-upper", got) + } + + if got := byNode[naming.Key("my_node")]; got != "pool-lower" { + t.Errorf("pool for my_node = %q, want pool-lower", got) + } +} + +// A replica created over HTTP shows its parents as the store resolved them, +// whatever reserved annotations the request carried. +func TestAnHTTPCreateCannotForgeTheShownParentNames(t *testing.T) { + t.Parallel() + + st := k8s.New(fake.NewClientBuilder().WithScheme(crScheme(t)).Build()) + ctx := t.Context() + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "lower-rd"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "lower-node"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + addr, stop := startServerWithStore(t, st) + defer stop() + + body, err := json.Marshal(apiv1.ResourceCreate{Resource: apiv1.Resource{ + NodeName: "lower-node", + Flags: []string{apiv1.ResourceFlagDiskless}, + Annotations: map[string]string{ + k8s.AnnotationLinstorRDName: "wrong-rd", + k8s.AnnotationLinstorNodeName: "wrong-node", + }, + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp := httpPost(t, addr+"/v1/resource-definitions/lower-rd/resources", body) + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create answered %d", resp.StatusCode) + } + + got, err := st.Resources().Get(ctx, "lower-rd", "lower-node") + if err != nil || got.Name != "lower-rd" || got.NodeName != "lower-node" { + t.Errorf("replica = %s/%s, %v; want lower-rd/lower-node", got.Name, got.NodeName, err) + } +} + +// additional_place_count counts the diskful replicas the definition keeps, +// and a replica on an EVICTED node registered as Node-1 is not one of them. +func TestAdditionalPlaceCountSkipsAReplicaOnAnEvictedMixedCaseNode(t *testing.T) { + t.Parallel() + + st := k8s.New(fake.NewClientBuilder().WithScheme(crScheme(t)).Build()) + ctx := t.Context() + + for _, n := range []apiv1.Node{ + {Name: "Node-1", Type: apiv1.NodeTypeSatellite, Flags: []string{apiv1.NodeFlagEvicted}}, + {Name: "Node-2", Type: apiv1.NodeTypeSatellite}, + } { + if err := st.Nodes().Create(ctx, &n); err != nil { + t.Fatalf("seed node %s: %v", n.Name, err) + } + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-add"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + for _, n := range []string{"Node-1", "Node-2"} { + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-add", NodeName: n}); err != nil { + t.Fatalf("seed replica on %s: %v", n, err) + } + } + + filter := apiv1.AutoSelectFilter{AdditionalPlaceCount: 1} + if err := (&Server{Store: st}).resolveAdditionalPlaceCount(ctx, "pvc-add", &filter); err != nil { + t.Fatalf("resolve: %v", err) + } + + if filter.PlaceCount != 2 { + t.Errorf("place_count = %d, want 2: one live diskful replica plus one", filter.PlaceCount) + } +} + +// The error-report node filter matches a node in any spelling that maps to +// its CR name, and keeps My_Node and my_node apart. +func TestErrorReportsFilterByNodeCRName(t *testing.T) { + srv := &Server{ + Addr: pickFreeAddr(t), + Store: store.NewInMemory(), + Client: newFakeRESTClient(t), + Namespace: testRESTNamespace, + } + + for _, node := range []string{"Node-1", "My_Node", "my_node"} { + srv.RecordErrorReport(ErrorReportEntry{Filename: node + ".log", NodeName: node, ErrorTime: 100}) + } + + base, stop := startServerCustom(t, srv) + defer stop() + + for query, want := range map[string]string{"node-1": "Node-1.log", "my_node": "my_node.log", "My_Node": "My_Node.log"} { + resp := httpGet(t, base+"/v1/error-reports?node="+query) + + var got []ErrorReportEntry + + err := json.NewDecoder(resp.Body).Decode(&got) + _ = resp.Body.Close() + + if err != nil || len(got) != 1 || got[0].Filename != want { + t.Errorf("?node=%s = %v, %v; want only %s", query, got, err, want) + } + } +} diff --git a/pkg/rest/node_connections.go b/pkg/rest/node_connections.go index bb4276ca..345c77e4 100644 --- a/pkg/rest/node_connections.go +++ b/pkg/rest/node_connections.go @@ -22,6 +22,7 @@ import ( "context" "maps" "net/http" + "slices" "sort" "strings" @@ -31,6 +32,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" k8sstore "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -88,18 +90,51 @@ const ( objRefNodeB = "NodeB" ) -// nodeConnectionPairKey returns the canonical persistence key for the -// (a, b) pair: lexicographically lower name first, then `::`, then -// the higher. The separator can't appear inside a LINSTOR node name -// (validated to `[A-Za-z0-9_-]+`), so the join is unambiguous. +// nodeConnectionPairKey returns the persistence key a new (a, b) pair is +// stored under: the node with the lower naming key first, then `::`, then +// the other, each in the spelling the caller gave, which the list shows. +// The separator can't appear inside a LINSTOR node name (validated to +// `[A-Za-z0-9_-]+`), so the join is unambiguous. func nodeConnectionPairKey(a, b string) string { - if a > b { + if naming.Key(a) > naming.Key(b) { a, b = b, a } return a + "::" + b } +// storedPairKeys returns the keys conn holds the (a, b) pair under, in any +// order and spelling of the two nodes, sorted. A pair written as +// Node-1::node-2 is the pair node-1, node-2 asks for. +func storedPairKeys(conn map[string]map[string]string, nodeA, nodeB string) []string { + var keys []string + + for key := range conn { + x, y, ok := strings.Cut(key, "::") + if !ok { + continue + } + + if naming.Same(x, nodeA) && naming.Same(y, nodeB) || naming.Same(x, nodeB) && naming.Same(y, nodeA) { + keys = append(keys, key) + } + } + + slices.Sort(keys) + + return keys +} + +// pairKeyIn is the key conn keeps the (a, b) pair under, or the key a new +// pair is stored under when it keeps none. +func pairKeyIn(conn map[string]map[string]string, a, b string) string { + if keys := storedPairKeys(conn, a, b); len(keys) > 0 { + return keys[0] + } + + return nodeConnectionPairKey(a, b) +} + // handleNodeConnectionList serves `GET /v1/node-connections` and // `GET /v1/node-connections/{src}` (the optional-filter form). The // filter form is what the python CLI uses for @@ -118,7 +153,7 @@ func (s *Server) handleNodeConnectionList(w http.ResponseWriter, r *http.Request filtered := pairs[:0] for _, p := range pairs { - if p.NodeA == filter || p.NodeB == filter { + if naming.Same(p.NodeA, filter) || naming.Same(p.NodeB, filter) { filtered = append(filtered, p) } } @@ -322,9 +357,7 @@ func (s *Server) readNodeConnectionProps(ctx context.Context, src, dst string) ( return nil, errors.Wrap(err, "get ControllerConfig") } - key := nodeConnectionPairKey(src, dst) - - stored := ctrlConfig.Spec.NodeConnections[key] + stored := ctrlConfig.Spec.NodeConnections[pairKeyIn(ctrlConfig.Spec.NodeConnections, src, dst)] if stored == nil { return map[string]string{}, nil } @@ -399,11 +432,9 @@ func (s *Server) readAllNodeConnections(ctx context.Context) ([]nodeConnectionWi func (s *Server) applyNodeConnectionProps( ctx context.Context, src, dst string, modify *apiv1.GenericPropsModify, ) error { - key := nodeConnectionPairKey(src, dst) - err := k8sstore.PatchControllerNodeConnections(ctx, s.Client, func(conn map[string]map[string]string) error { - mergeNodeConnectionPropsInto(conn, key, modify) + mergeNodeConnectionPropsInto(conn, pairKeyIn(conn, src, dst), modify) return nil }) @@ -420,11 +451,11 @@ func (s *Server) applyNodeConnectionProps( // pair has no props after this call", which a missing record // already satisfies. func (s *Server) dropNodeConnection(ctx context.Context, src, dst string) error { - key := nodeConnectionPairKey(src, dst) - err := k8sstore.PatchControllerNodeConnections(ctx, s.Client, func(conn map[string]map[string]string) error { - delete(conn, key) + for _, key := range storedPairKeys(conn, src, dst) { + delete(conn, key) + } return nil }) diff --git a/pkg/rest/node_connections_test.go b/pkg/rest/node_connections_test.go index 59740b14..2b0607ca 100644 --- a/pkg/rest/node_connections_test.go +++ b/pkg/rest/node_connections_test.go @@ -468,3 +468,69 @@ func TestNodeConnectionDeleteProp(t *testing.T) { t.Errorf("post-delete list: got %d pairs, want 0 (emptied pair must drop out)", len(pairs)) } } + +// A node is one node in every spelling: a pair written for Node-1 is listed +// when the filter says node-1, and a later write that spells it node-1 lands +// on the same pair instead of starting a second one. +func TestNodeConnectionMatchesANodeInAnySpelling(t *testing.T) { + base, stop := startServerWithStore(t, seedNodeConnectionEndpoints(t, "Node-1", "beta")) + defer stop() + + put := func(path string, props map[string]string) { + t.Helper() + + body, err := json.Marshal(nodeConnectionPutBody{OverrideProps: props}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, base+path, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT %s: status %d, want 200", path, resp.StatusCode) + } + } + + list := func(path string) []nodeConnectionWire { + t.Helper() + + resp := httpGet(t, base+path) + defer func() { _ = resp.Body.Close() }() + + var pairs []nodeConnectionWire + if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil { + t.Fatalf("decode: %v", err) + } + + return pairs + } + + put("/v1/node-connections/Node-1/beta", map[string]string{"Sites/Site": "site-x"}) + + if got := list("/v1/node-connections/node-1"); len(got) != 1 { + t.Errorf("filter node-1 found %d pairs, want the Node-1 pair", len(got)) + } + + put("/v1/node-connections/node-1/beta", map[string]string{"Sites/Rack": "r1"}) + + got := list("/v1/node-connections") + if len(got) != 1 { + t.Fatalf("two writes to one pair left %d pairs: %+v", len(got), got) + } + + if got[0].Props["Sites/Site"] != "site-x" || got[0].Props["Sites/Rack"] != "r1" { + t.Errorf("pair props %v, want both writes on one pair", got[0].Props) + } +} diff --git a/pkg/rest/node_lifecycle.go b/pkg/rest/node_lifecycle.go index dc79160a..5204277a 100644 --- a/pkg/rest/node_lifecycle.go +++ b/pkg/rest/node_lifecycle.go @@ -30,6 +30,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -134,7 +135,7 @@ func (s *Server) handleNodeEvacuate(w http.ResponseWriter, r *http.Request) { for i := range resources { res := &resources[i] - if res.NodeName != name { + if !naming.Same(res.NodeName, name) { continue } @@ -283,11 +284,13 @@ func queryFlag(r *http.Request, name string) bool { // evacuatingSet folds the variadic node list into a lookup map. // Kept separate from the handler body so the pre-check helpers -// share the same shape without re-allocating per call. +// share the same shape without re-allocating per call. It is keyed +// by CR name, like isEvacuating reads it, so node-1 in the request +// and Node-1 on the wire are one node. func evacuatingSet(names []string) map[string]bool { out := make(map[string]bool, len(names)) for _, name := range names { - out[name] = true + out[naming.Key(name)] = true } return out @@ -328,7 +331,7 @@ func (s *Server) checkEvacuateCandidate(ctx context.Context, w http.ResponseWrit for i := range allNodes { n := &allNodes[i] - if evacuating[n.Name] { + if evacuating[naming.Key(n.Name)] { continue } @@ -364,7 +367,7 @@ func (s *Server) checkEvacuateInUse(ctx context.Context, w http.ResponseWriter, for i := range resources { res := &resources[i] - if !evacuating[res.NodeName] { + if !evacuating[naming.Key(res.NodeName)] { continue } diff --git a/pkg/rest/node_lifecycle_test.go b/pkg/rest/node_lifecycle_test.go index 470b4878..ea43c8b4 100644 --- a/pkg/rest/node_lifecycle_test.go +++ b/pkg/rest/node_lifecycle_test.go @@ -1350,3 +1350,33 @@ func TestNodeEvacuateSingleInUseWireCompatibleWithMulti(t *testing.T) { t.Errorf("n1 stamped EVICTED despite refusal from both forms: %v", got.Flags) } } + +// The in-use guard reads replicas as the wire spells their node, Node-1, +// while the evacuate names the node in any spelling: a Primary on Node-1 +// still refuses `n evacuate node-1`. +func TestNodeEvacuateRefusedWhenInUseInAnotherSpelling(t *testing.T) { + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "rd-busy", + NodeName: "Node-1", + State: apiv1.ResourceState{InUse: boolPtr(true)}, + }); err != nil { + t.Fatalf("seed busy resource: %v", err) + } + + base, stop := startServerWithStore(t, st) + defer stop() + + resp := httpPost(t, base+"/v1/nodes/node-1/evacuate", nil) + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusConflict { + t.Fatalf("status: got %d, want 409 for a Primary on Node-1", resp.StatusCode) + } +} diff --git a/pkg/rest/node_spelling_wire_test.go b/pkg/rest/node_spelling_wire_test.go new file mode 100644 index 00000000..c58fb1bc --- /dev/null +++ b/pkg/rest/node_spelling_wire_test.go @@ -0,0 +1,460 @@ +// 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 rest + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/placer" + "github.com/cozystack/blockstor/pkg/store" +) + +// The wire spells a replica's node as it was registered, Node-1, while an +// operator, a URL or a pool may spell it node-1. Every path that picks a +// node's own replica out of a listing reads the two as one node. +func nodeSpellingStore(t *testing.T) store.Store { + t.Helper() + + st := store.NewInMemory() + ctx := t.Context() + + for _, name := range []string{"Node-1", "Node-2"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: name}); err != nil { + t.Fatalf("seed node %s: %v", name, err) + } + } + + for _, node := range []string{"Node-1", "Node-2"} { + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-mix", + NodeName: node, + Props: map[string]string{"StorPoolName": "pool-" + node}, + Volumes: []apiv1.Volume{{VolumeNumber: 0, StoragePool: "data"}}, + }); err != nil { + t.Fatalf("seed replica on %s: %v", node, err) + } + } + + return st +} + +func TestPoolReferencesSeeAReplicaInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + refs, err := (&Server{Store: nodeSpellingStore(t)}).referencingResources(t.Context(), "node-1", "data") + if err != nil || len(refs) != 1 { + t.Fatalf("references of data on node-1 = %v, %v; want the replica on Node-1", refs, err) + } +} + +func TestFindStoragePoolByNameMatchesAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + pools := &crdv1alpha1.StoragePoolList{Items: []crdv1alpha1.StoragePool{{ + Spec: crdv1alpha1.StoragePoolSpec{NodeName: "node-1", PoolName: "data"}, + }}} + + if findStoragePoolByName(pools, "Node-1", "data") == nil { + t.Error("pool data created on node-1 not found for Node-1") + } +} + +func TestTakeoverPoolSkipsTheTakeoverNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + pool, err := (&Server{Store: nodeSpellingStore(t)}).resolveTakeoverStorPool(t.Context(), "pvc-mix", "node-1") + if err != nil || pool != "pool-Node-2" { + t.Errorf("takeover pool for node-1 = %q, %v; want the sibling's pool-Node-2", pool, err) + } +} + +func TestPeerBumpSkipsTheRemovedNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := nodeSpellingStore(t) + (&Server{Store: st}).bumpPeerChangedOnSiblings(t.Context(), "pvc-mix", "node-1") + + removed, err := st.Resources().Get(t.Context(), "pvc-mix", "Node-1") + if err != nil { + t.Fatalf("get the removed row: %v", err) + } + + if _, bumped := removed.Annotations[apiv1.PeerChangedAnnotation]; bumped { + t.Error("the row of the removed node-1 was bumped as a sibling") + } +} + +// The removed replica itself is never waited on for an acknowledgment: with +// only that replica left the wait returns at once. +func TestPeerDeleteWaitSkipsTheRemovedNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-mix", NodeName: "Node-1"}); err != nil { + t.Fatalf("seed replica: %v", err) + } + + waitCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + start := time.Now() + (&Server{Store: st}).waitForPeerDeletionAcks(waitCtx, "pvc-mix", "node-1") + + if took := time.Since(start); took > time.Second { + t.Errorf("waited %v for the removed replica's own acknowledgment", took) + } +} + +// A single-node create names the node twice, in the URL and in the body; +// two spellings of one node are not a conflict. +func TestSingleNodeCreateTakesTheBodyNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-mix"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + base, stop := startServerWithStore(t, st) + defer stop() + + resp := httpPost(t, base+"/v1/resource-definitions/pvc-mix/resources/node-1", + []byte(`[{"resource":{"node_name":"Node-1","flags":["DISKLESS"]}}]`)) + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusBadRequest { + t.Fatalf("status 400 for body node Node-1 on URL node node-1") + } +} + +// A satellite reports its snapshot entry under its own node name, node-1, +// while the snapshot lists the node as registered, Node-1, and the replica +// listing does too. The snapshot is successful. +func TestSnapshotIsSuccessfulWithAnEntryInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-mix", NodeName: "Node-1"}); err != nil { + t.Fatalf("seed replica: %v", err) + } + + diskful, err := (&Server{Store: st}).diskfulPeerSet(ctx, "pvc-mix") + if err != nil { + t.Fatalf("diskfulPeerSet: %v", err) + } + + for _, reporter := range []string{"node-1", "NODE-1"} { + snap := &apiv1.Snapshot{ + Nodes: []string{"Node-1"}, + Snapshots: []apiv1.SnapshotPerNode{{NodeName: reporter, CreateTimestamp: 1}}, + } + + if !isSnapshotSuccessful(snap, diskful) { + t.Errorf("the snapshot taken on %s for Node-1 reads as not successful", reporter) + } + } +} + +// A restore onto node-1 takes the pool of the source's replica on Node-1. +func TestRestorePoolIsTheSourceReplicasOnAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + byNode, _ := storPoolsByNodeFromSourceRD(t.Context(), nodeSpellingStore(t), "pvc-mix") + if pool := byNode[naming.Key("node-1")]; pool != "pool-Node-1" { + t.Errorf("pool for node-1 = %q, want pool-Node-1", pool) + } +} + +// A multi-node evacuate names the nodes as the operator spells them, +// node-1, while the store lists them, and their replicas, as registered, +// Node-1. Both guards read the two as one node. +func TestEvacuateGuardsSeeTheNodesInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := nodeSpellingStore(t) + s := &Server{Store: st} + evacuating := evacuatingSet([]string{"node-1", "node-2"}) + + rec := httptest.NewRecorder() + if s.checkEvacuateCandidate(t.Context(), rec, []string{"node-1", "node-2"}, evacuating) { + t.Errorf("evacuating node-1 and node-2 found a candidate among Node-1 and Node-2") + } + + inUse := true + + res, err := st.Resources().Get(t.Context(), "pvc-mix", "Node-1") + if err != nil { + t.Fatalf("get replica: %v", err) + } + + res.State.InUse = &inUse + if err := st.Resources().Update(t.Context(), &res); err != nil { + t.Fatalf("mark replica in use: %v", err) + } + + rec = httptest.NewRecorder() + if s.checkEvacuateInUse(t.Context(), rec, evacuatingSet([]string{"NODE-1"})) { + t.Errorf("evacuating NODE-1 passed over the in-use replica on Node-1") + } +} + +// The disabled set and the pools it filters come from two spellings, the +// node as registered, Node-1, and a pool created as node-1: advise and +// query-size-info leave out the pool of an EVICTED node either way. +func TestPoolPickersLeaveOutAnEvictedNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1", Flags: []string{apiv1.NodeFlagEvicted}}); err != nil { + t.Fatalf("seed node: %v", err) + } + + disabled, err := (&Server{Store: st}).disabledNodes(ctx) + if err != nil { + t.Fatalf("disabledNodes: %v", err) + } + + for _, spelling := range []string{"node-1", "Node-1", "NODE-1"} { + pools := []apiv1.StoragePool{{NodeName: spelling, StoragePoolName: "data", FreeCapacity: 100, TotalCapacity: 100}} + + if picks := pickAdvicePools(pools, &apiv1.AutoSelectFilter{}, disabled, 1); len(picks) != 0 { + t.Errorf("advise picked the pool on %s of the EVICTED Node-1: %+v", spelling, picks) + } + + if candidates, _, _ := collectCandidates(pools, disabled, nil); len(candidates) != 0 { + t.Errorf("query-size-info counted the pool on %s of the EVICTED Node-1", spelling) + } + } +} + +// A restore pins its autoplace to the snapshot's nodes, which the snapshot +// lists as registered, Node-3, while the node's pool was created as node-3. +// The restore still places there. +func TestARestorePlacesOnTheSnapshotNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + for i, name := range []string{"Node-1", "Node-2", "Node-3"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: name, Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("seed node %s: %v", name, err) + } + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + NodeName: strings.ToLower(name), StoragePoolName: "data", ProviderKind: apiv1.StoragePoolKindLVMThin, + FreeCapacity: int64(3-i) * 100, TotalCapacity: 1000, + }); err != nil { + t.Fatalf("seed pool on %s: %v", name, err) + } + } + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "src"}); err != nil { + t.Fatalf("seed src: %v", err) + } + + if err := st.Snapshots().Create(ctx, &apiv1.Snapshot{ResourceName: "src", Name: "snap", Nodes: []string{"Node-3"}}); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + rd := &apiv1.ResourceDefinition{Name: "dst", Props: map[string]string{"BlockstorRestoreFromSnapshot": "src:snap"}} + filter := &apiv1.AutoSelectFilter{PlaceCount: 1} + constrainAutoplaceToSnapshotNodes(ctx, st, rd, filter) + + if _, _, err := placer.New(st).Place(ctx, "dst", filter); err != nil { + t.Fatalf("Place: %v", err) + } + + got, err := st.Resources().ListByDefinition(ctx, "dst") + if err != nil || len(got) != 1 || !strings.EqualFold(got[0].NodeName, "node-3") { + t.Fatalf("restore placed %+v, %v; want one replica on Node-3", got, err) + } +} + +// A snapshot's target set lists its nodes as registered, Node-1, and a +// replica may spell its node node-1: the non-snapshot pool under it is still +// in scope and refuses the snapshot. +func TestNonSnapshotPoolCheckScopesTheTargetsInAnotherSpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1"}); err != nil { + t.Fatalf("seed node: %v", err) + } + + if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{ + NodeName: "node-1", StoragePoolName: "thick", ProviderKind: apiv1.StoragePoolKindLVM, + }); err != nil { + t.Fatalf("seed pool: %v", err) + } + + replicas := []apiv1.Resource{{Name: "pvc-mix", NodeName: "node-1", Props: map[string]string{storPoolPropKey: "thick"}}} + + locs := (&Server{Store: st}).nonSnapshotPoolLocations(ctx, replicas, []string{"Node-1"}) + if len(locs) != 1 { + t.Fatalf("offenders = %v, want the thick pool on node-1", locs) + } +} + +// Paths posted for one pair under two spellings of a node land in one +// entry, read back under either spelling and either order: two entries +// would hand the satellite two connections between the same two hosts. +func TestResourceConnectionPathsKeepOneEntryPerPairInAnySpelling(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-mix"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + base, stop := startServerWithStore(t, st) + defer stop() + + conn := base + "/v1/resource-definitions/pvc-mix/resource-connections/" + + for _, post := range []struct{ pair, body string }{ + {"Node-1/beta", `{"name":"p1","node_a_address":"10.0.0.1","node_b_address":"10.0.0.2"}`}, + {"beta/node-1", `{"name":"p2","node_a_address":"10.0.1.2","node_b_address":"10.0.1.1"}`}, + } { + resp := httpPost(t, conn+post.pair+"/paths", []byte(post.body)) + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("POST %s: %d", post.pair, resp.StatusCode) + } + } + + rd, err := st.ResourceDefinitions().Get(ctx, "pvc-mix") + if err != nil { + t.Fatalf("get definition: %v", err) + } + + var entries []string + + for key := range rd.Props { + if strings.HasPrefix(key, resourceConnectionPathsPropKey) { + entries = append(entries, key) + } + } + + if len(entries) != 1 { + t.Fatalf("path entries %v; want one for the pair", entries) + } + + paths, swap, err := loadPaths(&rd, "node-1", "beta") + if swap { + paths = swapAll(paths) + } + + if err != nil || len(paths) != 2 || paths[0].NodeAAddress != "10.0.0.1" || paths[1].NodeAAddress != "10.0.1.1" { + t.Errorf("paths node-1/beta = %+v, %v; want p1 and p2 with node-1's address first", paths, err) + } +} + +// A sibling acknowledges forgetting a peer under the peer's key: the wait +// for Node-1 finds the acknowledgment stamped for node-1. +func TestPeerDeleteWaitFindsTheAcknowledgmentUnderTheRemovedNodesKey(t *testing.T) { + t.Parallel() + + st := store.NewInMemory() + ctx := t.Context() + + for _, node := range []string{"Node-1", "node-2"} { + if err := st.Nodes().Create(ctx, &apiv1.Node{Name: node}); err != nil { + t.Fatalf("seed node %s: %v", node, err) + } + } + + if err := st.Resources().Create(ctx, &apiv1.Resource{ + Name: "pvc-mix", NodeName: "node-2", + Annotations: map[string]string{peerForgetAckAnnotationPrefix + naming.Key("Node-1"): "2026-11-04T00:00:00Z"}, + }); err != nil { + t.Fatalf("seed sibling: %v", err) + } + + waitCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + start := time.Now() + (&Server{Store: st}).waitForPeerDeletionAcks(waitCtx, "pvc-mix", "Node-1") + + if took := time.Since(start); took > time.Second { + t.Errorf("waited %v for an acknowledgment the sibling already stamped", took) + } +} + +// migrate-disk from Node-1 to node-1 names one replica twice; it is refused +// before anything is stamped. +func TestMigrateDiskRefusesOneNodeInTwoSpellings(t *testing.T) { + t.Parallel() + + st := nodeSpellingStore(t) + ctx := t.Context() + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-mix"}); err != nil { + t.Fatalf("seed definition: %v", err) + } + + base, stop := startServerWithStore(t, st) + defer stop() + + resp := httpPut(t, base+"/v1/resource-definitions/pvc-mix/resources/node-1/migrate-disk/Node-1", nil) + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("migrate-disk node-1 from Node-1: %d, want 400", resp.StatusCode) + } + + res, err := st.Resources().Get(ctx, "pvc-mix", "node-1") + if err != nil { + t.Fatalf("get the replica: %v", err) + } + + if _, stamped := res.Props[MigratingFromProp]; stamped { + t.Error("a migration marker naming the replica's own node was stamped") + } +} diff --git a/pkg/rest/nodes.go b/pkg/rest/nodes.go index 6212b114..59b1c627 100644 --- a/pkg/rest/nodes.go +++ b/pkg/rest/nodes.go @@ -474,7 +474,7 @@ func (s *Server) handleNodeCreate(w http.ResponseWriter, r *http.Request) { return } - // Bug 97: validate at the wire boundary, before pkg/store/k8s.Name() + // Bug 97: validate at the wire boundary, before pkg/naming.Key() // slugifies + hash-prefixes the input. Same rationale as // handleRDCreate; see pkg/rest/input_validation.go. nameErr := validateLinstorName("node", n.Name) diff --git a/pkg/rest/peer_delete_sync.go b/pkg/rest/peer_delete_sync.go index 47b13ec9..9bce75a4 100644 --- a/pkg/rest/peer_delete_sync.go +++ b/pkg/rest/peer_delete_sync.go @@ -24,6 +24,7 @@ import ( "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -49,7 +50,8 @@ var peerDeletePollInterval = 250 * time.Millisecond // peerForgetAckAnnotationPrefix names the per-peer ACK key the // satellite stamps after completing ActionForgetPeer for a given peer // name. Full key shape: `blockstor.io/peer-forget-acked.` -// with an RFC3339Nano timestamp as the value. The REST handler's +// with an RFC3339Nano timestamp as the value; the peer is named by its +// key, so every spelling of it asks after one annotation. The REST handler's // waitForPeerDeletionAcks polls the key for presence as the implicit // confirmation that del-peer + forget-peer have completed against // the OLD peer incarnation. @@ -105,7 +107,7 @@ func (s *Server) waitForPeerDeletionAcks(ctx context.Context, rdName, removedNod for i := range siblings { sib := &siblings[i] - if sib.NodeName == removedNode { + if naming.Same(sib.NodeName, removedNode) { continue } @@ -120,7 +122,7 @@ func (s *Server) waitForPeerDeletionAcks(ctx context.Context, rdName, removedNod return } - ackKey := peerForgetAckAnnotationPrefix + removedNode + ackKey := peerForgetAckAnnotationPrefix + naming.Key(removedNode) deadline := time.Now().Add(peerDeleteAckTimeout) for { diff --git a/pkg/rest/physical_storage.go b/pkg/rest/physical_storage.go index 3bf32486..8621205a 100644 --- a/pkg/rest/physical_storage.go +++ b/pkg/rest/physical_storage.go @@ -31,6 +31,7 @@ import ( crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -442,7 +443,7 @@ func setStoragePoolOwnership(ctx context.Context, c ctrlclient.Client, deviceNam func findStoragePoolByName(pools *crdv1alpha1.StoragePoolList, nodeName, poolName string) *crdv1alpha1.StoragePool { for i := range pools.Items { p := &pools.Items[i] - if p.Spec.NodeName == nodeName && p.Spec.PoolName == poolName { + if naming.Same(p.Spec.NodeName, nodeName) && naming.Same(p.Spec.PoolName, poolName) { return p } } diff --git a/pkg/rest/query_size_info.go b/pkg/rest/query_size_info.go index 49e90f2f..75b7d48d 100644 --- a/pkg/rest/query_size_info.go +++ b/pkg/rest/query_size_info.go @@ -24,6 +24,7 @@ import ( "net/http" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // registerQuerySizeInfo wires `linstor query-size-info` and the @@ -179,11 +180,11 @@ func collectCandidates(pools []apiv1.StoragePool, disabled map[string]struct{}, continue } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { continue } - if filter != nil && filter.StoragePool != "" && pool.StoragePoolName != filter.StoragePool { + if filter != nil && filter.StoragePool != "" && !naming.Same(pool.StoragePoolName, filter.StoragePool) { continue } @@ -357,7 +358,8 @@ func replicaCount(filter *apiv1.AutoSelectFilter) int { // disabledNodes mirrors placer's same-named helper for the // query-size-info path (we don't want to count EVICTED/LOST node -// pools toward "available" capacity). +// pools toward "available" capacity). It is keyed by CR name, so a +// pool created as node-1 on a node registered as Node-1 is found. func (s *Server) disabledNodes(ctx context.Context) (map[string]struct{}, error) { nodes, err := s.Store.Nodes().List(ctx) if err != nil { @@ -369,7 +371,7 @@ func (s *Server) disabledNodes(ctx context.Context) (map[string]struct{}, error) for i := range nodes { for _, f := range nodes[i].Flags { if f == apiv1.NodeFlagEvicted || f == apiv1.NodeFlagLost { - out[nodes[i].Name] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} break } diff --git a/pkg/rest/rd_clone.go b/pkg/rest/rd_clone.go index 87b91e98..d7fae6e2 100644 --- a/pkg/rest/rd_clone.go +++ b/pkg/rest/rd_clone.go @@ -30,10 +30,12 @@ import ( "github.com/LINBIT/golinstor/client" "github.com/LINBIT/golinstor/clonestatus" "github.com/cockroachdb/errors" + "sigs.k8s.io/controller-runtime/pkg/log" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/cozystack/blockstor/pkg/validate" - "sigs.k8s.io/controller-runtime/pkg/log" ) // rdCloneRequest is the body for `resource-definition clone`. Only the @@ -867,7 +869,7 @@ func liveCloneSnapshot(ctx context.Context, st store.Store, srcName, snapName st } for i := range snaps { - if strings.EqualFold(snaps[i].Name, snapName) { + if naming.Same(snaps[i].Name, snapName) { return snaps[i], nil } } diff --git a/pkg/rest/resource_connections.go b/pkg/rest/resource_connections.go index 3420a912..4b891dca 100644 --- a/pkg/rest/resource_connections.go +++ b/pkg/rest/resource_connections.go @@ -21,11 +21,15 @@ package rest import ( "context" "encoding/json" + "maps" "net/http" + "slices" + "strings" "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // registerResourceConnections wires the resource-connection / paths @@ -116,25 +120,47 @@ type resourceConnectionPath struct { } // canonicalConnectionKey returns (sortedA, sortedB, swap) where -// sortedA <= sortedB lexicographically. swap is true when the caller +// sortedA's key sorts before sortedB's. swap is true when the caller // passed the pair in reverse order; the handler uses that to flip // A/B addresses on the way in/out so the stored entry always lives // under the canonical order. func canonicalConnectionKey(a, b string) (string, string, bool) { - if a <= b { + if naming.Key(a) <= naming.Key(b) { return a, b, false } return b, a, true } -// propKey is the full RD-prop key for a (nodeA, nodeB) pair. The pair -// is canonicalised before joining so the key is identical whether -// the operator posted under (a, b) or (b, a). -func propKey(nodeA, nodeB string) string { - sortedA, sortedB, _ := canonicalConnectionKey(nodeA, nodeB) +// pairPropKey is the RD-prop key rd keeps the (nodeA, nodeB) paths +// under: the one already stored for the pair, in whatever spelling and +// order it was posted, or a new one in canonical order. swap is true +// when the key names nodeB first. Looking the pair up by key keeps a +// second spelling of a node from opening a second entry for one pair, +// which would hand the satellite two connections between two hosts. +func pairPropKey(rd *apiv1.ResourceDefinition, nodeA, nodeB string) (string, bool) { + for _, key := range slices.Sorted(maps.Keys(rd.Props)) { + pair, ok := strings.CutPrefix(key, resourceConnectionPathsPropKey) + if !ok { + continue + } + + first, second, ok := strings.Cut(pair, "/") + if !ok { + continue + } + + switch { + case naming.Same(first, nodeA) && naming.Same(second, nodeB): + return key, false + case naming.Same(first, nodeB) && naming.Same(second, nodeA): + return key, true + } + } + + sortedA, sortedB, swap := canonicalConnectionKey(nodeA, nodeB) - return resourceConnectionPathsPropKey + sortedA + "/" + sortedB + return resourceConnectionPathsPropKey + sortedA + "/" + sortedB, swap } // loadPaths reads + decodes the stored path list for the (nodeA, @@ -142,9 +168,9 @@ func propKey(nodeA, nodeB string) string { // canonicalConnectionKey's third return so callers know whether to // flip A/B before encoding the response. func loadPaths(rd *apiv1.ResourceDefinition, nodeA, nodeB string) ([]resourceConnectionPath, bool, error) { - _, _, swap := canonicalConnectionKey(nodeA, nodeB) + key, swap := pairPropKey(rd, nodeA, nodeB) - raw, ok := rd.Props[propKey(nodeA, nodeB)] + raw, ok := rd.Props[key] if !ok || raw == "" { return nil, swap, nil } @@ -163,7 +189,7 @@ func loadPaths(rd *apiv1.ResourceDefinition, nodeA, nodeB string) ([]resourceCon // slice to delete the prop key entirely (so an emptied connection // doesn't leak as a zombie `[]` value in storage). func storePaths(rd *apiv1.ResourceDefinition, nodeA, nodeB string, paths []resourceConnectionPath) error { - key := propKey(nodeA, nodeB) + key, _ := pairPropKey(rd, nodeA, nodeB) if len(paths) == 0 { delete(rd.Props, key) diff --git a/pkg/rest/resource_definitions.go b/pkg/rest/resource_definitions.go index 1c0b772f..20fdf0f1 100644 --- a/pkg/rest/resource_definitions.go +++ b/pkg/rest/resource_definitions.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -54,14 +55,15 @@ func (s *Server) handleRDList(w http.ResponseWriter, r *http.Request) { // golinstor as the comma-joined `?resource_definitions=a,b` form. // Without honouring this, `rd l --resource-definitions X` returns // the full RD list and the CLI renders ALL RDs — Bug 61. - // Semantics mirror upstream: case-insensitive name match, + // Semantics mirror upstream: names match as the store keys them + // (naming.Same, case-insensitive inside RFC 1123), // unknown names => empty list (NOT 404); missing param => no filter. nameFilter := multiValueQuery(r, "resource_definitions") if len(nameFilter) > 0 { filtered := rds[:0] for i := range rds { - if matchAnyFold(nameFilter, rds[i].Name) { + if matchAnyName(nameFilter, rds[i].Name) { filtered = append(filtered, rds[i]) } } @@ -317,7 +319,7 @@ func (s *Server) handleRDCreate(w http.ResponseWriter, r *http.Request) { // rg-modify validation gate) would reach the store unchecked. Re-validate // the resolved (possibly inherited) stack here, mirroring the create // path's 400, so an unmaterialisable layer chain never persists onto an RD. - lsErr := validateLayerStack(rd.LayerStack) + lsErr := canonicalizeLayerStack(&rd.LayerStack) if lsErr != nil { writeError(w, http.StatusBadRequest, lsErr.Error()) @@ -432,7 +434,7 @@ func (s *Server) validateRDCreateBody(w http.ResponseWriter, r *http.Request, bo } // Bug 97: refuse whitespace-only / RFC-1123-illegal names at the - // REST wire boundary, BEFORE pkg/store/k8s.Name() slugifies + + // REST wire boundary, BEFORE pkg/naming.Key() slugifies + // hash-prefixes the input. Without this gate `linstor rd c " "` // leaked the raw apimachinery "metadata.name: Invalid value: // \"-\"" error and exposed the internal hash-prefix scheme; @@ -594,7 +596,7 @@ func (s *Server) refuseRDUpdateOnUnknownRG(w http.ResponseWriter, r *http.Reques } current, getErr := s.Store.ResourceDefinitions().Get(r.Context(), rdName) - if getErr == nil && current.ResourceGroupName == rgChange { + if getErr == nil && naming.Same(current.ResourceGroupName, rgChange) { return true } @@ -851,7 +853,7 @@ func (s *Server) validateRDLayerStackOnCreate(ctx context.Context, body *apiv1.R return err } - err = validateLayerStack(rd.LayerStack) + err = canonicalizeLayerStack(&rd.LayerStack) if err != nil { return err } diff --git a/pkg/rest/resource_group_extras.go b/pkg/rest/resource_group_extras.go index 1089f894..6b3dc6ce 100644 --- a/pkg/rest/resource_group_extras.go +++ b/pkg/rest/resource_group_extras.go @@ -30,6 +30,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -134,7 +135,7 @@ func (s *Server) handleStoragePoolDefinitionList(w http.ResponseWriter, r *http. props := map[string]string{} maps.Copy(props, defs[i].Props) - seen[defs[i].Name] = len(out) + seen[naming.Key(defs[i].Name)] = len(out) out = append(out, storagePoolDefinitionWire{ StoragePoolName: defs[i].Name, Props: props, @@ -142,11 +143,11 @@ func (s *Server) handleStoragePoolDefinitionList(w http.ResponseWriter, r *http. } for i := range pools { - if _, dup := seen[pools[i].StoragePoolName]; dup { + if _, dup := seen[naming.Key(pools[i].StoragePoolName)]; dup { continue } - seen[pools[i].StoragePoolName] = len(out) + seen[naming.Key(pools[i].StoragePoolName)] = len(out) out = append(out, storagePoolDefinitionWire{ StoragePoolName: pools[i].StoragePoolName, Props: map[string]string{}, @@ -659,11 +660,11 @@ func clusterPoolNames(ctx context.Context, s *Server) []string { for i := range pools { name := pools[i].StoragePoolName - if _, dup := seen[name]; dup { + if _, dup := seen[naming.Key(name)]; dup { continue } - seen[name] = struct{}{} + seen[naming.Key(name)] = struct{}{} out = append(out, name) } @@ -685,16 +686,16 @@ func poolNodeNamesFor(ctx context.Context, s *Server, pool string) []string { out := make([]string, 0, len(pools)) for i := range pools { - if pools[i].StoragePoolName != pool { + if !naming.Same(pools[i].StoragePoolName, pool) { continue } node := pools[i].NodeName - if _, dup := seen[node]; dup { + if _, dup := seen[naming.Key(node)]; dup { continue } - seen[node] = struct{}{} + seen[naming.Key(node)] = struct{}{} out = append(out, node) } diff --git a/pkg/rest/resource_groups.go b/pkg/rest/resource_groups.go index bac24c58..56abee4c 100644 --- a/pkg/rest/resource_groups.go +++ b/pkg/rest/resource_groups.go @@ -27,9 +27,11 @@ import ( "maps" "net/http" "slices" + "strings" "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/pkg/errors" @@ -77,14 +79,14 @@ func (s *Server) handleRGList(w http.ResponseWriter, r *http.Request) { // Optional filter the upstream LINSTOR CLI sends on // `linstor rg l --resource-groups ...` — mirrors the // `resource_definitions` filter on `rd l` (Bug 61) for the RG - // list endpoint. Same semantics: case-insensitive name match, + // list endpoint. Same semantics: names match as the store keys them, // unknown names => empty list, missing param => no filter. nameFilter := multiValueQuery(r, "resource_groups") if len(nameFilter) > 0 { filtered := rgs[:0] for i := range rgs { - if matchAnyFold(nameFilter, rgs[i].Name) { + if matchAnyName(nameFilter, rgs[i].Name) { filtered = append(filtered, rgs[i]) } } @@ -164,7 +166,7 @@ func (s *Server) handleRGCreate(w http.ResponseWriter, r *http.Request) { } // Bug 97: see pkg/rest/input_validation.go — RFC-1123 subdomain - // validation at the REST boundary, before pkg/store/k8s.Name() + // validation at the REST boundary, before pkg/naming.Key() // mangles the input. nameErr := validateLinstorName("resource group", rg.Name) if nameErr != nil { @@ -173,7 +175,7 @@ func (s *Server) handleRGCreate(w http.ResponseWriter, r *http.Request) { return } - err := validateLayerStack(rg.SelectFilter.LayerStack) + err := canonicalizeLayerStack(&rg.SelectFilter.LayerStack) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -373,7 +375,7 @@ func countChildRDs(ctx context.Context, st store.Store, rgName string) int { n := 0 for i := range rds { - if rds[i].ResourceGroupName == rgName { + if naming.Same(rds[i].ResourceGroupName, rgName) { n++ } } @@ -413,7 +415,10 @@ func rgNeedsRebalance(prev, next *apiv1.AutoSelectFilter) bool { return true } - if !slices.Equal(prev.LayerStack, next.LayerStack) { + // Case-blind: a stack stored as drbd,storage before writes were + // canonicalised is the same stack as DRBD,STORAGE, and a modify that + // resends it must not rebalance every child. + if !slices.EqualFunc(prev.LayerStack, next.LayerStack, strings.EqualFold) { return true } @@ -1037,7 +1042,7 @@ func rgUpdateWireGate(raw []byte, patch *apiv1.ResourceGroup) error { // nil); a nil/empty stack is the legitimate "clear to default" and // validateLayerStack accepts it. if patch.SelectFilter.LayerStack != nil { - err := validateLayerStack(patch.SelectFilter.LayerStack) + err := canonicalizeLayerStack(&patch.SelectFilter.LayerStack) if err != nil { return err } diff --git a/pkg/rest/resource_toggle_disk.go b/pkg/rest/resource_toggle_disk.go index 83645cd6..814e939c 100644 --- a/pkg/rest/resource_toggle_disk.go +++ b/pkg/rest/resource_toggle_disk.go @@ -25,6 +25,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -149,6 +150,16 @@ func (s *Server) migrateDiskCommon(w http.ResponseWriter, r *http.Request, pool return } + // One node in two spellings is one replica: it cannot be both the + // diskful source and the destination, and a marker naming it would + // tell the migration controller to prune the copy it just made. + if naming.Same(src, dst) { + writeError(w, http.StatusBadRequest, + "migrate-disk: source '"+src+"' and destination '"+dst+"' are the same node") + + return + } + if !s.validateMigrateSrc(w, r, rdName, src) { return } diff --git a/pkg/rest/resources.go b/pkg/rest/resources.go index b6ce6034..008f6b4c 100644 --- a/pkg/rest/resources.go +++ b/pkg/rest/resources.go @@ -27,6 +27,7 @@ import ( "strings" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // registerResources wires the /v1/view/resources aggregate. linstor-csi @@ -50,7 +51,8 @@ func (s *Server) handleResourcesView(w http.ResponseWriter, r *http.Request) { // `multiValueQuery` accepts both, so `linstor r l -r foo -n bar` // and linstor-csi's existing requests land in the same filter. // Java LINSTOR honours both as case-insensitive set-membership; we - // match that so linstor-csi's "is this resource on this node?" + // match names as the store keys them (naming.Same), which is that for + // every name inside RFC 1123, so linstor-csi's "is this resource on this node?" // poll returns a non-empty list when the answer is yes. nodeFilter := multiValueQuery(r, "nodes") rdFilter := multiValueQuery(r, "resources") @@ -69,19 +71,19 @@ func (s *Server) handleResourcesView(w http.ResponseWriter, r *http.Request) { vdSizes := vdSizeIndex(r.Context(), s, resList) for i := range resList { - if !matchAnyFold(nodeFilter, resList[i].NodeName) { + if !matchAnyName(nodeFilter, resList[i].NodeName) { continue } - if !matchAnyFold(rdFilter, resList[i].Name) { + if !matchAnyName(rdFilter, resList[i].Name) { continue } - if faultyOnly && !rdStats[resList[i].Name].faulty { + if faultyOnly && !rdStats[naming.Key(resList[i].Name)].faulty { continue } - rwv, buildErr := s.buildResourceView(r.Context(), &resList[i], vdSizes[resList[i].Name]) + rwv, buildErr := s.buildResourceView(r.Context(), &resList[i], vdSizes[naming.Key(resList[i].Name)]) if buildErr != nil { writeError(w, http.StatusInternalServerError, buildErr.Error()) @@ -395,7 +397,7 @@ func aggregateRDStats(resList []apiv1.Resource) map[string]rdFaultyStats { out := map[string]rdFaultyStats{} for i := range resList { - stats := out[resList[i].Name] + stats := out[naming.Key(resList[i].Name)] for j := range resList[i].Volumes { disk := resList[i].Volumes[j].State.DiskState @@ -412,7 +414,7 @@ func aggregateRDStats(resList []apiv1.Resource) map[string]rdFaultyStats { stats.faulty = true } - out[resList[i].Name] = stats + out[naming.Key(resList[i].Name)] = stats } return out @@ -453,8 +455,8 @@ func isUpToDateDiskState(disk string) bool { // Returns 0 when both belong to RDs with identical UpToDate counts — // the caller falls back to the deterministic Name+NodeName tiebreak. func compareResourceFaulty(stats map[string]rdFaultyStats, a, b *apiv1.ResourceWithVolumes) int { - au := stats[a.Name].upToDate - bu := stats[b.Name].upToDate + au := stats[naming.Key(a.Name)].upToDate + bu := stats[naming.Key(b.Name)].upToDate if au != bu { return au - bu @@ -520,7 +522,7 @@ func vdSizeIndex(ctx context.Context, s *Server, resList []apiv1.Resource) map[s seen := map[string]struct{}{} for i := range resList { - seen[resList[i].Name] = struct{}{} + seen[naming.Key(resList[i].Name)] = struct{}{} } for rd := range seen { @@ -534,7 +536,7 @@ func vdSizeIndex(ctx context.Context, s *Server, resList []apiv1.Resource) map[s idx[vds[j].VolumeNumber] = vds[j].SizeKib } - out[rd] = idx + out[naming.Key(rd)] = idx } return out @@ -708,15 +710,15 @@ func splitCSV(value string) []string { return out } -// matchAnyFold reports whether candidate matches any of needles -// case-insensitively. Empty needles means "no filter — accept". -func matchAnyFold(needles []string, candidate string) bool { +// matchAnyName reports whether candidate names the same object as any of +// needles (naming.Same). Empty needles means "no filter — accept". +func matchAnyName(needles []string, candidate string) bool { if len(needles) == 0 { return true } for _, n := range needles { - if strings.EqualFold(n, candidate) { + if naming.Same(n, candidate) { return true } } diff --git a/pkg/rest/rg_deleted_race.go b/pkg/rest/rg_deleted_race.go index d3189cf6..51358c1b 100644 --- a/pkg/rest/rg_deleted_race.go +++ b/pkg/rest/rg_deleted_race.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -1050,11 +1051,11 @@ func (s *Server) deleteReplicasNotYetTold( ctx context.Context, rdName string, nodes []string, told map[string]struct{}, ) error { for _, node := range nodes { - if _, done := told[node]; done { + if _, done := told[naming.Key(node)]; done { continue } - told[node] = struct{}{} + told[naming.Key(node)] = struct{}{} err := s.Store.Resources().Delete(ctx, rdName, node) if err != nil && !errors.Is(err, store.ErrNotFound) { diff --git a/pkg/rest/snapshot_restore.go b/pkg/rest/snapshot_restore.go index 63436d44..c0e24c43 100644 --- a/pkg/rest/snapshot_restore.go +++ b/pkg/rest/snapshot_restore.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/cozystack/blockstor/pkg/validate" ) @@ -1026,15 +1027,14 @@ func restoreMarker(srcRD, snapName string) string { // restoreMarkerMatches reports whether a definition was produced by this // restore or clone. // -// The comparison is case-insensitive because the two sides reach it from -// different places: the marker is written from the stored objects, and a -// caller derives the other side from names it spelled itself. LINSTOR folds -// name case and pkg/store/k8s/crdname.go lowercases every lookup key, so both -// spellings address one object — and a byte comparison here would answer that -// somebody else owns a definition this restore created, which is the -// terminal-on-first-failure behaviour the resume path exists to end. +// The two sides reach it from different places: the marker is written from the +// stored objects, and a caller derives the other side from names it spelled +// itself. Both spellings address one object, and a byte comparison here would +// answer that somebody else owns a definition this restore created, which is +// the terminal-on-first-failure behaviour the resume path exists to end; see +// store.SameRestoreMarker. func restoreMarkerMatches(props map[string]string, srcRD, snapName string) bool { - return strings.EqualFold(props[restoreFromSnapshotKey], restoreMarker(srcRD, snapName)) + return store.SameRestoreMarker(props[restoreFromSnapshotKey], restoreMarker(srcRD, snapName)) } // validateRestoreNodesHoldSnapshot is the Bug 397 input-validation guard @@ -1150,7 +1150,7 @@ func (s *Server) leftoverIsThisRestore( func (s *Server) requestedShapeDiffers( ctx context.Context, existing *apiv1.ResourceDefinition, rgName string, layers []string, ) (string, error) { - if rgName != "" && !strings.EqualFold(existing.ResourceGroupName, rgName) { + if rgName != "" && !naming.Same(existing.ResourceGroupName, rgName) { return "resource group '" + existing.ResourceGroupName + "', not '" + rgName + "'", nil } @@ -1773,18 +1773,18 @@ func (s *Server) stampRestoredResourcesOnNodes(ctx context.Context, srcRDName, n continue } - if _, dup := seen[node]; dup { + if _, dup := seen[naming.Key(node)]; dup { continue } - seen[node] = struct{}{} + seen[naming.Key(node)] = struct{}{} res := apiv1.Resource{ Name: newRDName, NodeName: node, } - pool := poolByNode[node] + pool := poolByNode[naming.Key(node)] if pool == "" { pool = fallbackPool } @@ -1979,7 +1979,7 @@ func storPoolsByNodeFromSourceRD(ctx context.Context, st store.Store, srcRDName continue } - byNode[resList[i].NodeName] = pool + byNode[naming.Key(resList[i].NodeName)] = pool if fallback == "" { fallback = pool diff --git a/pkg/rest/snapshots.go b/pkg/rest/snapshots.go index 55672bf6..162af9a3 100644 --- a/pkg/rest/snapshots.go +++ b/pkg/rest/snapshots.go @@ -28,6 +28,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -167,8 +168,8 @@ func (s *Server) handleSnapshotsView(w http.ResponseWriter, r *http.Request) { stripInternalAnnotationsFromSnapshots(snaps) // Optional filters golinstor sends: ?resources=rd1,rd2 & - // snapshots=name1,name2 — case-insensitive set membership against - // Java LINSTOR's behaviour. Without filtering linstor-csi's "do + // snapshots=name1,name2 — set membership by naming.Same, Java + // LINSTOR's case-insensitive match for every name inside RFC 1123. Without filtering linstor-csi's "do // any snapshots exist for this volume?" poll has to scan the // whole cluster every cycle. rdFilter := multiValueQuery(r, "resources") @@ -177,11 +178,11 @@ func (s *Server) handleSnapshotsView(w http.ResponseWriter, r *http.Request) { filtered := make([]apiv1.Snapshot, 0, len(snaps)) for i := range snaps { - if !matchAnyFold(rdFilter, snaps[i].ResourceName) { + if !matchAnyName(rdFilter, snaps[i].ResourceName) { continue } - if !matchAnyFold(nameFilter, snaps[i].Name) { + if !matchAnyName(nameFilter, snaps[i].Name) { continue } @@ -367,7 +368,7 @@ func (s *Server) stampSnapshotSuccessful(ctx context.Context, snaps []apiv1.Snap continue } - diskful, ok := diskfulByRD[snap.ResourceName] + diskful, ok := diskfulByRD[naming.Key(snap.ResourceName)] if !ok { built, err := s.diskfulPeerSet(ctx, snap.ResourceName) if err != nil { @@ -375,7 +376,7 @@ func (s *Server) stampSnapshotSuccessful(ctx context.Context, snaps []apiv1.Snap } diskful = built - diskfulByRD[snap.ResourceName] = diskful + diskfulByRD[naming.Key(snap.ResourceName)] = diskful } if isSnapshotSuccessful(snap, diskful) { @@ -446,11 +447,11 @@ func isSnapshotSuccessful(snap *apiv1.Snapshot, diskful map[string]struct{}) boo continue } - reported[entry.NodeName] = struct{}{} + reported[naming.Key(entry.NodeName)] = struct{}{} } for node := range denominator { - if _, ok := reported[node]; !ok { + if _, ok := reported[naming.Key(node)]; !ok { return false } } @@ -470,8 +471,8 @@ func successDenominator(snap *apiv1.Snapshot, diskful map[string]struct{}) map[s out := make(map[string]struct{}, len(snap.Nodes)) for _, node := range snap.Nodes { - if _, ok := diskful[node]; ok { - out[node] = struct{}{} + if _, ok := diskful[naming.Key(node)]; ok { + out[naming.Key(node)] = struct{}{} } } @@ -516,7 +517,7 @@ func (s *Server) diskfulPeerSet(ctx context.Context, rdName string) (map[string] continue } - out[resList[i].NodeName] = struct{}{} + out[naming.Key(resList[i].NodeName)] = struct{}{} } return out, nil @@ -729,7 +730,7 @@ func validateSnapshotCreateBody(w http.ResponseWriter, r *http.Request, rd strin // // Bug 97: tighten further to the full RFC-1123 subdomain rule so // `linstor s c "Foo Bar"` is refused with a LINSTOR envelope - // before pkg/store/k8s.Name() slugifies the input. + // before pkg/store/naming.Key() slugifies the input. snapNameErr := validateLinstorName("snapshot", strings.TrimSpace(snap.Name)) if snapNameErr != nil { writeError(w, http.StatusBadRequest, snapNameErr.Error()) @@ -1065,9 +1066,11 @@ func (s *Server) refuseSnapshotOnNonSnapshotPool(w http.ResponseWriter, r *http. // mixed-provider RD is not falsely refused. An empty targetNodes means // "all diskful replicas" (the un-pinned default-fan-out case). func (s *Server) nonSnapshotPoolLocations(ctx context.Context, resList []apiv1.Resource, targetNodes []string) []string { + // Keyed by CR name: snap.Nodes lists a node as registered, and a + // replica written before the store stored CR names may spell it so too. want := make(map[string]struct{}, len(targetNodes)) for _, n := range targetNodes { - want[n] = struct{}{} + want[naming.Key(n)] = struct{}{} } var offenders []poolOffender @@ -1079,7 +1082,7 @@ func (s *Server) nonSnapshotPoolLocations(ctx context.Context, resList []apiv1.R // Scope to the resolved target set when the caller pinned one. if len(want) > 0 { - if _, ok := want[res.NodeName]; !ok { + if _, ok := want[naming.Key(res.NodeName)]; !ok { continue } } diff --git a/pkg/rest/storage_pool_definitions.go b/pkg/rest/storage_pool_definitions.go index d1e42569..5ae4afb4 100644 --- a/pkg/rest/storage_pool_definitions.go +++ b/pkg/rest/storage_pool_definitions.go @@ -25,6 +25,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -333,7 +334,7 @@ func (s *Server) refuseSPDfnDeleteIfReferenced(w http.ResponseWriter, r *http.Re var refs []string for i := range pools { - if pools[i].StoragePoolName != name { + if !naming.Same(pools[i].StoragePoolName, name) { continue } diff --git a/pkg/rest/storage_pools.go b/pkg/rest/storage_pools.go index eb1eeb21..6a91bd18 100644 --- a/pkg/rest/storage_pools.go +++ b/pkg/rest/storage_pools.go @@ -28,6 +28,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" "github.com/cozystack/blockstor/pkg/validate" ) @@ -369,7 +370,7 @@ func (s *Server) referencingResources(ctx context.Context, node, pool string) ([ var refs []string for i := range all { - if all[i].NodeName != node { + if !naming.Same(all[i].NodeName, node) { continue } @@ -377,7 +378,7 @@ func (s *Server) referencingResources(ctx context.Context, node, pool string) ([ for j := range all[i].Volumes { vol := &all[i].Volumes[j] - if vol.StoragePool == pool { + if naming.Same(vol.StoragePool, pool) { refs = append(refs, all[i].Name+"/"+strconv.FormatInt(int64(vol.VolumeNumber), 10)) @@ -394,7 +395,7 @@ func (s *Server) referencingResources(ctx context.Context, node, pool string) ([ // post-delete re-scan would never see the racing // resource and the SP delete would proceed even though // the spec-only Resource is about to dangle. - if !matched && all[i].Props["StorPoolName"] == pool { + if !matched && naming.Same(all[i].Props["StorPoolName"], pool) { refs = append(refs, all[i].Name+"/spec") } } @@ -433,8 +434,8 @@ func (s *Server) handleStoragePoolsView(w http.ResponseWriter, r *http.Request) // pipeline that /v1/view/storage-pools uses rather than the store's // ListByNode shortcut. Java LINSTOR matches node names case-insensitively on // both paths, and ListByNode compares the stored spelling (see -// store.FoldName), so routing the per-node handler through the same -// matchAnyFold filter is what keeps the two endpoints in lockstep — a parity +// pkg/store/k8s/spellings.go), so routing the per-node handler through the same +// matchAnyName filter is what keeps the two endpoints in lockstep — a parity // invariant the storage_pools_test.go MatchesViewFiltering test pins. // // This used to give a second reason, that ListByNode ran a label selector a @@ -469,7 +470,8 @@ func redactStoragePoolsInPlace(pools []apiv1.StoragePool) { } // listFilteredStoragePools returns the wire-shape pool slice after applying -// the case-insensitive node / pool filters Java LINSTOR uses. Empty filters +// the node / pool filters Java LINSTOR uses, matching names as the store keys +// them (naming.Same). Empty filters // mean "no constraint on this dimension". The slice is always non-nil so // JSON encoding yields [] instead of null — linstor-csi rejects null bodies. func (s *Server) listFilteredStoragePools(ctx context.Context, nodeFilter, poolFilter []string) ([]apiv1.StoragePool, error) { @@ -481,11 +483,11 @@ func (s *Server) listFilteredStoragePools(ctx context.Context, nodeFilter, poolF out := make([]apiv1.StoragePool, 0, len(pools)) for i := range pools { - if !matchAnyFold(nodeFilter, pools[i].NodeName) { + if !matchAnyName(nodeFilter, pools[i].NodeName) { continue } - if !matchAnyFold(poolFilter, pools[i].StoragePoolName) { + if !matchAnyName(poolFilter, pools[i].StoragePoolName) { continue } diff --git a/pkg/rest/storage_pools_test.go b/pkg/rest/storage_pools_test.go index 5b2dd32e..128f297a 100644 --- a/pkg/rest/storage_pools_test.go +++ b/pkg/rest/storage_pools_test.go @@ -398,7 +398,7 @@ func TestStoragePoolsPerNodeListMatchesViewFiltering(t *testing.T) { // Same elements in same order — both endpoints sort the // underlying slice consistently (List sorts by (node,pool) - // and matchAnyFold preserves order). + // and matchAnyName preserves order). for i := range perNode { if perNode[i].NodeName != viewFiltered[i].NodeName || perNode[i].StoragePoolName != viewFiltered[i].StoragePoolName { diff --git a/pkg/rest/upstream_parity_bug_225_229.go b/pkg/rest/upstream_parity_bug_225_229.go index 01dcc8e8..6ca47b81 100644 --- a/pkg/rest/upstream_parity_bug_225_229.go +++ b/pkg/rest/upstream_parity_bug_225_229.go @@ -25,6 +25,7 @@ import ( "strings" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // This file gathers four upstream-parity endpoints blockstor was @@ -339,8 +340,8 @@ func (s *Server) handleNodeConfigPut(w http.ResponseWriter, r *http.Request) { // handleStoragePoolDefinitionSingle serves Bug 229. Walks the // StoragePool table for entries whose `StoragePoolName` matches the -// requested name (case-insensitive — matches upstream's -// `equalsIgnoreCase` filter), dedups by name, and returns the +// requested name as the store keys it (naming.Same; upstream's +// `equalsIgnoreCase` for every name inside RFC 1123), dedups by name, and returns the // filtered slice in the same wire shape the list endpoint emits. // 404 on an unknown name so `linstor sp-definition l ` surfaces // a typed error instead of an empty-list confusion. @@ -376,7 +377,7 @@ func (s *Server) handleStoragePoolDefinitionSingle(w http.ResponseWriter, r *htt } for i := range pools { - if !strings.EqualFold(pools[i].StoragePoolName, want) { + if !naming.Same(pools[i].StoragePoolName, want) { continue } diff --git a/pkg/satellite/connection_spelling_test.go b/pkg/satellite/connection_spelling_test.go new file mode 100644 index 00000000..76ff8ee8 --- /dev/null +++ b/pkg/satellite/connection_spelling_test.go @@ -0,0 +1,63 @@ +// 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 satellite + +import ( + "strings" + "testing" + + intent "github.com/cozystack/blockstor/pkg/satellite/intent" +) + +// A connection with paths names its nodes as the request spelled them, +// Node-1 and Node-2, while the hosts carry the replicas' CR names, node-1 and +// node-2. The paths still reach the .res file. +func TestAPathStoredInTheRegisteredSpellingIsRendered(t *testing.T) { + t.Parallel() + + dr := &intent.DesiredResource{ + Name: "pvc-path", + NodeName: "node-1", + Volumes: []*intent.DesiredVolume{{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "lvm1"}}, + Peers: []intent.DesiredPeer{{Name: "node-2"}}, + DrbdOptions: map[string]string{ + "port": "7000", + "node-id": "0", + "address": "10.0.0.1", + "minor": "1000", + "peer.node-2.address": "10.0.0.2", + "peer.node-2.node-id": "1", + "peer.node-2.port": "7000", + }, + Connections: []intent.DesiredConnection{{ + NodeA: "Node-1", + NodeB: "Node-2", + Paths: []intent.DesiredConnectionPath{{Name: "path1", AddressA: "192.0.2.1", AddressB: "192.0.2.2"}}, + }}, + } + + body, err := buildResFile(dr, "node-1", "10.0.0.1", map[int32]string{0: "/dev/lvm1/pvc-path_00000"}, nil) + if err != nil { + t.Fatalf("buildResFile: %v", err) + } + + if !strings.Contains(body, "path {") || !strings.Contains(body, "192.0.2.2") { + t.Errorf("the path stored for Node-1 and Node-2 is not rendered:\n%s", body) + } +} diff --git a/pkg/satellite/controllers/cr_names.go b/pkg/satellite/controllers/cr_names.go new file mode 100644 index 00000000..1c119eb7 --- /dev/null +++ b/pkg/satellite/controllers/cr_names.go @@ -0,0 +1,88 @@ +// 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 controllers + +import ( + "context" + + "github.com/cockroachdb/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// The kernel, the storage provider and the intent know a resource by its +// definition's CR name: pvc-mix, or a hash for a name outside RFC 1123. +// The store names a replica's Resource CR, and a Snapshot CR, from the +// spellings the definition and the node were registered under, and for a +// hashed definition those do not hash back from the CR name. So a CR a +// satellite reaches from a kernel name is named through the registered +// spellings, the way the store named it at create. + +// replicaCRName names the Resource CR of the replica of rdName on node. +func replicaCRName(ctx context.Context, reader client.Reader, rdName, node string) (string, error) { + rd, err := registeredRDName(ctx, reader, rdName) + if err != nil { + return "", err + } + + var crd blockstoriov1alpha1.Node + + err = reader.Get(ctx, client.ObjectKey{Name: naming.Key(node)}, &crd) + + switch { + case err == nil: + node = k8s.OriginalName(&crd.ObjectMeta) + case !apierrors.IsNotFound(err): + return "", errors.Wrapf(err, "get Node %s", node) + } + + return k8s.ResourceCRName(rd, node), nil +} + +// snapshotCRName names the Snapshot CR snap of rdName. +func snapshotCRName(ctx context.Context, reader client.Reader, rdName, snap string) (string, error) { + rd, err := registeredRDName(ctx, reader, rdName) + if err != nil { + return "", err + } + + return k8s.SnapshotCRName(rd, snap), nil +} + +// registeredRDName is the spelling rdName's definition was registered +// under. A definition that is not there leaves rdName as it is: whatever +// CR the caller reaches for is not there either. +func registeredRDName(ctx context.Context, reader client.Reader, rdName string) (string, error) { + var rd blockstoriov1alpha1.ResourceDefinition + + err := reader.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd) + if apierrors.IsNotFound(err) { + return rdName, nil + } + + if err != nil { + return "", errors.Wrapf(err, "get ResourceDefinition %s", rdName) + } + + return k8s.OriginalName(&rd.ObjectMeta), nil +} diff --git a/pkg/satellite/controllers/discovered_storage.go b/pkg/satellite/controllers/discovered_storage.go index c4a347cc..eb0eabf9 100644 --- a/pkg/satellite/controllers/discovered_storage.go +++ b/pkg/satellite/controllers/discovered_storage.go @@ -32,6 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/storage" "github.com/cozystack/blockstor/pkg/storage/lvm" ) @@ -181,7 +182,7 @@ func (d *DiscoveredStorageRunnable) tick(ctx context.Context, logger logr.Logger var node blockstoriov1alpha1.Node - err := d.Client.Get(ctx, types.NamespacedName{Name: d.NodeName}, &node) + err := d.Client.Get(ctx, types.NamespacedName{Name: naming.Key(d.NodeName)}, &node) if err != nil { if apierrors.IsNotFound(err) { // Mirrors HeartbeatRunnable's `node lost` carve-out: diff --git a/pkg/satellite/controllers/filesystem_formatted_stamper.go b/pkg/satellite/controllers/filesystem_formatted_stamper.go index 83ad51c9..5995147a 100644 --- a/pkg/satellite/controllers/filesystem_formatted_stamper.go +++ b/pkg/satellite/controllers/filesystem_formatted_stamper.go @@ -57,18 +57,19 @@ type FilesystemFormattedStamper struct { } // StampFilesystemFormatted SSA-patches a `FilesystemFormatted=True` -// Condition onto Resource .Status.Conditions. +// Condition onto the Resource CR of the replica of resourceName on +// nodeName (see replicaCRName). // Idempotent — SSA's listMap merging on `type` means a repeat // patch with the same fields is a no-op at the apiserver level // (LastTransitionTime is preserved because the apiserver only // updates it when the Condition's Status actually changes). -func (s *FilesystemFormattedStamper) StampFilesystemFormatted(ctx context.Context, resourceName string) error { - return s.stampFilesystemFormattedCondition(ctx, resourceName, +func (s *FilesystemFormattedStamper) StampFilesystemFormatted(ctx context.Context, resourceName, nodeName string) error { + return s.stampFilesystemFormattedCondition(ctx, resourceName, nodeName, "MkfsSucceeded", "all diskful volumes formatted") } // StampFilesystemObserved SSA-patches a `FilesystemFormatted=True` -// Condition onto Resource .Status.Conditions with a +// Condition onto the replica's Resource CR (see replicaCRName) with a // distinct `Reason` so the audit trail differentiates satellite-driven // mkfs (`Reason=MkfsSucceeded`) from externally-created filesystems // observed via blkid (`Reason=FilesystemObserved`). @@ -84,8 +85,8 @@ func (s *FilesystemFormattedStamper) StampFilesystemFormatted(ctx context.Contex // FilesystemFormatted]` — and crucially, NEVER touches // `.status.volumes` (a separate listMap keyed on volumeNumber, // owned by the volume-status / observer writers). -func (s *FilesystemFormattedStamper) StampFilesystemObserved(ctx context.Context, resourceName string) error { - return s.stampFilesystemFormattedCondition(ctx, resourceName, +func (s *FilesystemFormattedStamper) StampFilesystemObserved(ctx context.Context, resourceName, nodeName string) error { + return s.stampFilesystemFormattedCondition(ctx, resourceName, nodeName, "FilesystemObserved", "filesystem detected on every diskful volume (mkfs not run by satellite)") } @@ -94,7 +95,14 @@ func (s *FilesystemFormattedStamper) StampFilesystemObserved(ctx context.Context // guarantees byte-level equivalence (modulo Reason/Message) across both // callers, which is what makes the listMap merge under // filesystemFormattedFieldOwner safe. -func (s *FilesystemFormattedStamper) stampFilesystemFormattedCondition(ctx context.Context, resourceName, reason, message string) error { +func (s *FilesystemFormattedStamper) stampFilesystemFormattedCondition(ctx context.Context, rdName, nodeName, reason, message string) error { + // The apply chain knows the replica by the definition's and the + // node's CR names; the CR is named the way the store named it. + resourceName, err := replicaCRName(ctx, s.Client, rdName, nodeName) + if err != nil { + return err + } + apply := &blockstoriov1alpha1.Resource{ TypeMeta: metav1.TypeMeta{ Kind: resourceKind, @@ -119,7 +127,7 @@ func (s *FilesystemFormattedStamper) stampFilesystemFormattedCondition(ctx conte // entry. The MetadataCreated stamper keeps its own entry, and // future Condition writers (toggle-disk failure, etc.) layer in // alongside without conflict. - err := s.Client.Status().Patch(ctx, apply, + err = s.Client.Status().Patch(ctx, apply, client.Apply, //nolint:staticcheck // SA1019: applyconfiguration-gen output not yet available for our CRDs client.FieldOwner(filesystemFormattedFieldOwner)) if err != nil { diff --git a/pkg/satellite/controllers/filesystem_formatted_stamper_test.go b/pkg/satellite/controllers/filesystem_formatted_stamper_test.go index 92660037..580fb135 100644 --- a/pkg/satellite/controllers/filesystem_formatted_stamper_test.go +++ b/pkg/satellite/controllers/filesystem_formatted_stamper_test.go @@ -49,12 +49,12 @@ import ( // regression that wedged PR #32's predecessor (`.status.volumes` was // reset to null and every e2e lane failed with `volumes":null`). func TestFilesystemFormattedAndObservedSharedSSAShape(t *testing.T) { - formattedPatch := captureStamperPatch(t, "fs-rd.n1", func(s *FilesystemFormattedStamper, ctx context.Context, name string) error { - return s.StampFilesystemFormatted(ctx, name) + formattedPatch := captureStamperPatch(t, "fs-rd", func(s *FilesystemFormattedStamper, ctx context.Context, name string) error { + return s.StampFilesystemFormatted(ctx, name, "n1") }) - observedPatch := captureStamperPatch(t, "fs-rd.n1", func(s *FilesystemFormattedStamper, ctx context.Context, name string) error { - return s.StampFilesystemObserved(ctx, name) + observedPatch := captureStamperPatch(t, "fs-rd", func(s *FilesystemFormattedStamper, ctx context.Context, name string) error { + return s.StampFilesystemObserved(ctx, name, "n1") }) if formattedPatch.fieldOwner != observedPatch.fieldOwner { diff --git a/pkg/satellite/controllers/heartbeat.go b/pkg/satellite/controllers/heartbeat.go index f62843fb..44812c72 100644 --- a/pkg/satellite/controllers/heartbeat.go +++ b/pkg/satellite/controllers/heartbeat.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" ) // HeartbeatPeriod is the cadence at which the satellite stamps @@ -184,7 +185,7 @@ func (h *HeartbeatRunnable) stamp(ctx context.Context, logger logr.Logger) error Kind: "Node", APIVersion: blockstoriov1alpha1.GroupVersion.String(), }, - ObjectMeta: metav1.ObjectMeta{Name: h.NodeName}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(h.NodeName)}, Status: blockstoriov1alpha1.NodeStatus{ LastHeartbeatTime: &now, Conditions: []metav1.Condition{ diff --git a/pkg/satellite/controllers/heartbeat_test.go b/pkg/satellite/controllers/heartbeat_test.go index 74369af2..4b9fa27f 100644 --- a/pkg/satellite/controllers/heartbeat_test.go +++ b/pkg/satellite/controllers/heartbeat_test.go @@ -128,3 +128,39 @@ func TestHeartbeatRunnable_NodeMissing(t *testing.T) { t.Fatalf("Start: %v", err) } } + +// A satellite started under the node's registered spelling, Node-1, stamps +// the Node the store keeps it under, node-1. +func TestHeartbeatRunnableStampsTheNodeUnderItsKey(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = blockstoriov1alpha1.AddToScheme(scheme) + + cli := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&blockstoriov1alpha1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Spec: blockstoriov1alpha1.NodeSpec{Type: "SATELLITE"}, + }). + WithStatusSubresource(&blockstoriov1alpha1.Node{}). + Build() + + hb := &controllers.HeartbeatRunnable{Client: cli, NodeName: "Node-1", Period: 100 * time.Millisecond} + + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + + if err := hb.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + var got blockstoriov1alpha1.Node + if err := cli.Get(t.Context(), client.ObjectKey{Name: "node-1"}, &got); err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Status.LastHeartbeatTime == nil { + t.Error("node-1 was not stamped by a satellite started as Node-1") + } +} diff --git a/pkg/satellite/controllers/layer_stack_pin.go b/pkg/satellite/controllers/layer_stack_pin.go index 2f0894c8..e287e9b7 100644 --- a/pkg/satellite/controllers/layer_stack_pin.go +++ b/pkg/satellite/controllers/layer_stack_pin.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // errLayerStackNotPinned reports an RD that leaves its layer stack to its @@ -62,7 +62,7 @@ func (r *ResourceReconciler) checkLayerStackPinned(ctx context.Context, rd *bloc // The RD keeps the group's name as the operator spelled it; the CR is // stored under the folded one. - err := r.Get(ctx, client.ObjectKey{Name: k8s.Name(rd.Spec.ResourceGroupName)}, &rg) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(rd.Spec.ResourceGroupName)}, &rg) if apierrors.IsNotFound(err) { return nil } diff --git a/pkg/satellite/controllers/metadata_created_backfill.go b/pkg/satellite/controllers/metadata_created_backfill.go index edfa786e..6079360b 100644 --- a/pkg/satellite/controllers/metadata_created_backfill.go +++ b/pkg/satellite/controllers/metadata_created_backfill.go @@ -30,6 +30,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/naming" ) // MetadataCreatedBackfillRunnable closes the Phase 11.3 Stage 1 @@ -140,7 +141,7 @@ func (b *MetadataCreatedBackfillRunnable) RegisterWithManager(mgr manager.Manage // (caller bumps the counter). Pulled out so Start stays under the // funlen budget. func (b *MetadataCreatedBackfillRunnable) backfillOne(ctx context.Context, res *blockstoriov1alpha1.Resource, logger logr.Logger) bool { - if res.Spec.NodeName != b.NodeName { + if !naming.Same(res.Spec.NodeName, b.NodeName) { return false } @@ -163,7 +164,7 @@ func (b *MetadataCreatedBackfillRunnable) backfillOne(ctx context.Context, res * return false } - stampErr := b.Stamper.StampMetadataCreated(ctx, res.Name) + stampErr := b.Stamper.stampOn(ctx, res.Name) if stampErr != nil { logger.Info("stamp MetadataCreated (best-effort)", "err", stampErr.Error()) diff --git a/pkg/satellite/controllers/metadata_created_stamper.go b/pkg/satellite/controllers/metadata_created_stamper.go index d3c68989..f6a1a3a0 100644 --- a/pkg/satellite/controllers/metadata_created_stamper.go +++ b/pkg/satellite/controllers/metadata_created_stamper.go @@ -53,12 +53,24 @@ type MetadataCreatedStamper struct { } // StampMetadataCreated SSA-patches a `MetadataCreated=True` -// Condition onto Resource .Status.Conditions. +// Condition onto the Resource CR of the replica of resourceName on +// nodeName, named the way the store named it (see replicaCRName). +func (s *MetadataCreatedStamper) StampMetadataCreated(ctx context.Context, resourceName, nodeName string) error { + name, err := replicaCRName(ctx, s.Client, resourceName, nodeName) + if err != nil { + return err + } + + return s.stampOn(ctx, name) +} + +// stampOn SSA-patches the Condition onto the Resource CR named +// resourceName. // Idempotent — SSA's listMap merging on `type` means a repeat // patch with the same fields is a no-op at the apiserver level // (LastTransitionTime is preserved because the apiserver only // updates it when the Condition's Status actually changes). -func (s *MetadataCreatedStamper) StampMetadataCreated(ctx context.Context, resourceName string) error { +func (s *MetadataCreatedStamper) stampOn(ctx context.Context, resourceName string) error { apply := &blockstoriov1alpha1.Resource{ TypeMeta: metav1.TypeMeta{ Kind: resourceKind, diff --git a/pkg/satellite/controllers/mixed_case_replica_apply_test.go b/pkg/satellite/controllers/mixed_case_replica_apply_test.go new file mode 100644 index 00000000..4858d6b0 --- /dev/null +++ b/pkg/satellite/controllers/mixed_case_replica_apply_test.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 controllers + +import ( + "context" + "testing" + + "github.com/cockroachdb/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/dispatcher" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +var errPastTheDefinition = errors.New("reached the peer read after the definition Get") + +// storedMixedCaseReplica registers a definition as Pvc-Mix and a node as +// Node-1, creates a replica of it through the store in yet another +// spelling, and returns the client and the replica as stored. +func storedMixedCaseReplica(t *testing.T, funcs interceptor.Funcs) (client.Client, *blockstoriov1alpha1.Resource) { + t.Helper() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Pvc-Mix")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") + + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Node-1")}} + k8s.SetOriginalName(&node.ObjectMeta, "Node-1") + + base := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&rd, &node).Build() + + ctx := context.Background() + + if err := k8s.New(base).Resources().Create(ctx, &apiv1.Resource{Name: "PVC-MIX", NodeName: "NODE-1"}); err != nil { + t.Fatalf("create the replica through the store: %v", err) + } + + var list blockstoriov1alpha1.ResourceList + if err := base.List(ctx, &list); err != nil || len(list.Items) != 1 { + t.Fatalf("list the stored replica = %v, %v", list.Items, err) + } + + res := list.Items[0] + res.Finalizers = append(res.Finalizers, SatelliteResourceFinalizer) + + if err := base.Update(ctx, &res); err != nil { + t.Fatalf("stamp the satellite finalizer: %v", err) + } + + return interceptor.NewClient(base, funcs), &res +} + +// The satellite applies a replica of a mixed-case definition: its reconcile +// finds the definition by the stored name and goes on to read the peers. +// With the registered spelling in spec the Get answered NotFound and the +// reconcile returned without applying or requeueing. +func TestSatelliteAppliesAReplicaOfAMixedCaseDefinition(t *testing.T) { + t.Parallel() + + cli, res := storedMixedCaseReplica(t, interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*blockstoriov1alpha1.ResourceList); ok { + return errPastTheDefinition + } + + return c.List(ctx, list, opts...) + }, + }) + + r := &ResourceReconciler{Client: cli, Config: Config{NodeName: "node-1"}} + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: res.Name}}) + if !errors.Is(err, errPastTheDefinition) { + t.Fatalf("reconcile = %v, want it past the definition Get into the peer read", err) + } +} + +// The DRBD resource and its volumes are named after the stored definition +// name, so a mixed-case definition keeps the lowercase names on disk that a +// replica written before has, pvc-mix and pvc-mix_00000. +func TestMixedCaseReplicaKeepsLowercaseNamesOnDisk(t *testing.T) { + t.Parallel() + + _, res := storedMixedCaseReplica(t, interceptor.Funcs{}) + + desired := dispatcher.BuildDesired(res, nil, nil, nil, nil, nil) + if desired.GetName() != "pvc-mix" || desired.GetNodeName() != "node-1" { + t.Errorf("desired resource %q on %q, want pvc-mix on node-1", desired.GetName(), desired.GetNodeName()) + } +} diff --git a/pkg/satellite/controllers/node_name_case_test.go b/pkg/satellite/controllers/node_name_case_test.go new file mode 100644 index 00000000..37096548 --- /dev/null +++ b/pkg/satellite/controllers/node_name_case_test.go @@ -0,0 +1,49 @@ +// 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 controllers + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" +) + +// A replica that spells its node Node-1, as one written or adopted before +// the store kept CR names may, +// belongs to the satellite started as node-1: the store keeps one Node for +// both. +func TestSatelliteTakesItsReplicaInAnySpelling(t *testing.T) { + t.Parallel() + + pred := nodeNamePredicate("node-1") + + for spelling, want := range map[string]bool{"node-1": true, "Node-1": true, "NODE-1": true, "node-2": false} { + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{NodeName: spelling}, + } + + if got := pred.Generic(event.GenericEvent{Object: res}); got != want { + t.Errorf("replica on %q taken by node-1 = %v, want %v", spelling, got, want) + } + } +} diff --git a/pkg/satellite/controllers/observer.go b/pkg/satellite/controllers/observer.go index 30fa9e1d..2e8b9eb7 100644 --- a/pkg/satellite/controllers/observer.go +++ b/pkg/satellite/controllers/observer.go @@ -1114,7 +1114,7 @@ func (o *ObserverRunnable) handleObservation(ctx context.Context, adm *drbd.Adm, // doesn't suppress the wake-up — the recovery decision only // needs the kernel-state change to land in the reconcile queue, // not the corresponding Status PATCH to commit. - o.emitReconcileTrigger(ev) + o.emitReconcileTrigger(ctx, ev) // Phase 11.3 Stage 3: stamp the KernelLoaded Condition off the // resource-kind frame lifecycle. Gated on HasKernelLoaded so @@ -1220,7 +1220,7 @@ func isDataBearingDiskState(s drbd.DiskState) bool { // SetupWithManager registers the channel as a raw source whose // handler enqueues the named Resource for reconciliation, no // per-event field inspection required. -func (o *ObserverRunnable) emitReconcileTrigger(ev *observation) { +func (o *ObserverRunnable) emitReconcileTrigger(ctx context.Context, ev *observation) { if o.ReconcileTrigger == nil || ev == nil || ev.ResourceName == "" { return } @@ -1247,7 +1247,11 @@ func (o *ObserverRunnable) emitReconcileTrigger(ev *observation) { o.lifecycleCache[ev.ResourceName] = want o.lifecycleMu.Unlock() - name := k8s.Name(ev.ResourceName + "." + o.NodeName) + name, err := replicaCRName(ctx, o.Client, ev.ResourceName, o.NodeName) + if err != nil { + // The 5-second resync re-enqueues every local Resource anyway. + return + } trigger := event.GenericEvent{ Object: &blockstoriov1alpha1.Resource{ @@ -1760,7 +1764,10 @@ func (o *ObserverRunnable) writeStatus(ctx context.Context, ev *observation) err return nil } - name := k8s.Name(ev.ResourceName + "." + o.NodeName) + name, err := replicaCRName(ctx, o.Client, ev.ResourceName, o.NodeName) + if err != nil { + return err + } // Best-effort lookup for `Spec.StoragePool` so the observer's // per-volume Status entries carry the pool name on every tick @@ -1824,7 +1831,7 @@ func (o *ObserverRunnable) writeStatus(ctx context.Context, ev *observation) err // volumes[volumeNumber=0]; ForceOwnership on either side would // kick the other's subfield claims off the listMap entry. // SSA's per-field merge already covers what we need. - err := o.Client.Status().Patch(ctx, apply, + err = o.Client.Status().Patch(ctx, apply, client.Apply, //nolint:staticcheck // SA1019: applyconfiguration-gen output not yet available client.FieldOwner(k8s.SatelliteFieldOwner)) if err != nil { @@ -1901,7 +1908,10 @@ func (o *ObserverRunnable) writeSkipDiskProp(ctx context.Context, resourceName s return nil } - name := k8s.Name(resourceName + "." + o.NodeName) + name, err := replicaCRName(ctx, o.Client, resourceName, o.NodeName) + if err != nil { + return err + } // Read the existing Resource so the SSA apply object can carry // the immutable required scalars (`resourceDefinitionName`, @@ -1917,7 +1927,7 @@ func (o *ObserverRunnable) writeSkipDiskProp(ctx context.Context, resourceName s // IsNotFound branch silences the event. var existing blockstoriov1alpha1.Resource - err := o.Client.Get(ctx, client.ObjectKey{Name: name}, &existing) + err = o.Client.Get(ctx, client.ObjectKey{Name: name}, &existing) if err != nil { return errors.Wrapf(err, "get Resource %s", name) } @@ -1989,7 +1999,10 @@ func (o *ObserverRunnable) stampKernelLoaded(ctx context.Context, resourceName s return nil } - name := k8s.Name(resourceName + "." + o.NodeName) + name, err := replicaCRName(ctx, o.Client, resourceName, o.NodeName) + if err != nil { + return err + } status := metav1.ConditionFalse reason := kernelLoadedReasonDestroyed @@ -2024,7 +2037,7 @@ func (o *ObserverRunnable) stampKernelLoaded(ctx context.Context, resourceName s // writer own only the KernelLoaded entry. The MetadataCreated // and FilesystemFormatted stampers keep their own entries // alive under their own field-managers. - err := o.Client.Status().Patch(ctx, apply, + err = o.Client.Status().Patch(ctx, apply, client.Apply, //nolint:staticcheck // SA1019: applyconfiguration-gen output not yet available client.FieldOwner(observerKernelLoadedFieldOwner)) if err != nil { diff --git a/pkg/satellite/controllers/observer_cr_name_test.go b/pkg/satellite/controllers/observer_cr_name_test.go new file mode 100644 index 00000000..8def76a6 --- /dev/null +++ b/pkg/satellite/controllers/observer_cr_name_test.go @@ -0,0 +1,266 @@ +// 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 controllers + +import ( + "context" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + 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/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// The kernel knows a resource by its definition's CR name: a hash for +// My_Res, pvc-mix for an old row that keeps Pvc-Mix in spec, the name +// itself for a plain pvc-. The observer writes onto the Resource CR +// the store created, named from the registered spellings. +func TestTheObserverWritesOntoTheReplicaTheStoreNamed(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + registered string + specRD string + node string + }{ + {"My_Res", naming.Key("My_Res"), "Node-1"}, + {"Pvc-Mix", "Pvc-Mix", "Node-1"}, + {"pvc-0b5d6c1e-4f3a-4e2b-9a1d-7c8e9f0a1b2c", "pvc-0b5d6c1e-4f3a-4e2b-9a1d-7c8e9f0a1b2c", "Node-1"}, + {"pvc-0b5d6c1e-4f3a-4e2b-9a1d-7c8e9f0a1b2c", "pvc-0b5d6c1e-4f3a-4e2b-9a1d-7c8e9f0a1b2c", "My_Node"}, + } { + t.Run(tc.registered+"/"+tc.node, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("scheme: %v", err) + } + + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(tc.registered)}} + k8s.SetOriginalName(&rd.ObjectMeta, tc.registered) + + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(tc.node)}} + k8s.SetOriginalName(&node.ObjectMeta, tc.node) + + crName := k8s.ResourceCRName(tc.registered, tc.node) + replica := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: crName}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: tc.specRD, + NodeName: node.Name, + Props: map[string]string{"StorPoolName": "thin1"}, + }, + } + + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(rd, node, replica). + WithStatusSubresource(&blockstoriov1alpha1.Resource{}).Build() + + o := &ObserverRunnable{Client: cli, NodeName: node.Name} + ctx := context.Background() + kernel := naming.Key(tc.specRD) + + if err := o.writeStatus(ctx, &observation{ResourceName: kernel, DrbdState: "UpToDate", Role: "Secondary"}); err != nil { + t.Fatalf("writeStatus: %v", err) + } + + if err := o.stampKernelLoaded(ctx, kernel, true); err != nil { + t.Fatalf("stampKernelLoaded: %v", err) + } + + if err := o.writeSkipDiskProp(ctx, kernel); err != nil { + t.Fatalf("writeSkipDiskProp: %v", err) + } + + var got blockstoriov1alpha1.Resource + if err := cli.Get(ctx, client.ObjectKey{Name: crName}, &got); err != nil { + t.Fatalf("get %s: %v", crName, err) + } + + if got.Status.Role != "Secondary" || got.Status.DrbdState != "UpToDate" { + t.Errorf("status on %s = role %q state %q; want the observation", crName, got.Status.Role, got.Status.DrbdState) + } + + if !meta.IsStatusConditionTrue(got.Status.Conditions, blockstoriov1alpha1.ConditionKernelLoaded) { + t.Errorf("KernelLoaded on %s = %v; want True", crName, got.Status.Conditions) + } + + if got.Spec.Props[skipDiskPropKey] != skipDiskPropValue { + t.Errorf("SkipDisk on %s = %q; want %q", crName, got.Spec.Props[skipDiskPropKey], skipDiskPropValue) + } + + var all blockstoriov1alpha1.ResourceList + if err := cli.List(ctx, &all); err != nil || len(all.Items) != 1 { + t.Errorf("resources %d, %v; want only the store's one", len(all.Items), err) + } + }) + } +} + +func crNameScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("scheme: %v", err) + } + + return scheme +} + +// The SkipDisk clearer reaches the replica of a hashed definition the way +// the observer that stamped it does. +func TestTheSkipDiskClearerReachesAHashedDefinitionsReplica(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} + k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") + + crName := k8s.ResourceCRName("My_Res", "n1") + replica := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: crName}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: rd.Name, + NodeName: "n1", + }, + } + + cli := fake.NewClientBuilder().WithScheme(crNameScheme(t)).WithObjects(rd, replica).Build() + o := &ObserverRunnable{Client: cli, NodeName: "n1"} + + if err := o.writeSkipDiskProp(context.Background(), rd.Name); err != nil { + t.Fatalf("stamp: %v", err) + } + + var stamped blockstoriov1alpha1.Resource + if err := cli.Get(context.Background(), client.ObjectKey{Name: crName}, &stamped); err != nil || stamped.Spec.Props[skipDiskPropKey] != skipDiskPropValue { + t.Fatalf("stamp did not land on %s: %v, %v", crName, stamped.Spec.Props, err) + } + + if err := (&SkipDiskClearer{Client: cli, NodeName: "n1"}).ClearSkipDisk(context.Background(), rd.Name); err != nil { + t.Fatalf("clear: %v", err) + } + + var got blockstoriov1alpha1.Resource + if err := cli.Get(context.Background(), client.ObjectKey{Name: crName}, &got); err != nil { + t.Fatalf("get: %v", err) + } + + if _, ok := got.Spec.Props[skipDiskPropKey]; ok { + t.Errorf("SkipDisk still on %s after the clear: %v", crName, got.Spec.Props) + } +} + +// The cross-node fetcher finds a hashed definition's snapshot by the name +// the store gave it, from the definition's CR name the restore carries. +func TestTheSnapshotFetcherFindsAHashedDefinitionsSnapshot(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} + k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: k8s.SnapshotCRName("My_Res", "snap-1")}, + Spec: blockstoriov1alpha1.SnapshotSpec{ResourceDefinitionName: rd.Name, SnapshotName: "snap-1"}, + } + + cli := fake.NewClientBuilder().WithScheme(crNameScheme(t)).WithObjects(rd, snap).Build() + + _, _, err := (&SnapshotFetcher{Client: cli, NodeName: "n1"}).Fetch(context.Background(), rd.Name, "snap-1", 0) + if err == nil || !strings.Contains(err.Error(), "empty Spec.Nodes") { + t.Errorf("fetch = %v; want it to reach the snapshot, which names no peers", err) + } +} + +// A lifecycle change on a hashed definition wakes the reconcile of the +// replica the store named. +func TestTheObserverWakesAHashedDefinitionsReplica(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} + k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") + + trigger := make(chan event.GenericEvent, 1) + o := &ObserverRunnable{ + Client: fake.NewClientBuilder().WithScheme(crNameScheme(t)).WithObjects(rd).Build(), + NodeName: "n1", + ReconcileTrigger: trigger, + } + + o.emitReconcileTrigger(context.Background(), &observation{ResourceName: rd.Name}) + + select { + case ev := <-trigger: + if want := k8s.ResourceCRName("My_Res", "n1"); ev.Object.GetName() != want { + t.Errorf("woke %q, want %q", ev.Object.GetName(), want) + } + default: + t.Fatal("no reconcile trigger") + } +} + +// The MetadataCreated and FilesystemFormatted stampers land on the replica +// the store named, from the CR names the apply chain knows it by. +func TestTheConditionStampersReachAHashedDefinitionsReplica(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} + k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") + + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Node")}} + k8s.SetOriginalName(&node.ObjectMeta, "My_Node") + + crName := k8s.ResourceCRName("My_Res", "My_Node") + replica := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: crName}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: rd.Name, NodeName: node.Name}, + } + + for cond, stamp := range map[string]func(client.Client) error{ + blockstoriov1alpha1.ConditionMetadataCreated: func(c client.Client) error { + return (&MetadataCreatedStamper{Client: c}).StampMetadataCreated(context.Background(), rd.Name, node.Name) + }, + blockstoriov1alpha1.ConditionFilesystemFormatted: func(c client.Client) error { + return (&FilesystemFormattedStamper{Client: c}).StampFilesystemFormatted(context.Background(), rd.Name, node.Name) + }, + } { + cli := fake.NewClientBuilder().WithScheme(crNameScheme(t)).WithObjects(rd.DeepCopy(), node.DeepCopy(), replica.DeepCopy()). + WithStatusSubresource(&blockstoriov1alpha1.Resource{}).Build() + + if err := stamp(cli); err != nil { + t.Fatalf("%s: %v", cond, err) + } + + var got blockstoriov1alpha1.Resource + if err := cli.Get(context.Background(), client.ObjectKey{Name: crName}, &got); err != nil { + t.Fatalf("get: %v", err) + } + + if !meta.IsStatusConditionTrue(got.Status.Conditions, cond) { + t.Errorf("%s on %s = %v; want True", cond, crName, got.Status.Conditions) + } + } +} diff --git a/pkg/satellite/controllers/old_row_spelling_test.go b/pkg/satellite/controllers/old_row_spelling_test.go new file mode 100644 index 00000000..2821801a --- /dev/null +++ b/pkg/satellite/controllers/old_row_spelling_test.go @@ -0,0 +1,258 @@ +// 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 controllers + +import ( + "context" + "testing" + + "github.com/cockroachdb/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" +) + +// Replicas of one definition may carry two spellings of it: one written +// before the store stored CR names keeps Pvc-Mix and Node-2, a new one +// stores pvc-mix and node-1. The satellite reads them as one definition: +// an edit of it reaches the local replica, the peer list holds both, and +// the authoritative flags and the node-id check find the target among them. +func TestOldAndNewRowsOfADefinitionAreOneDefinition(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + local := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-mix", NodeName: "node-1"}, + } + old := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-2"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-2"}, + } + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}} + + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(local, old, rd).Build() + r := &ResourceReconciler{Client: cli, Config: Config{NodeName: "node-1"}} + ctx := context.Background() + + if reqs := r.enqueueResourcesForRD(ctx, rd); len(reqs) != 1 || reqs[0].Name != local.Name { + t.Errorf("an edit of pvc-mix enqueued %v, want %s", reqs, local.Name) + } + + if reqs := r.enqueueLocalSiblings(ctx, old); len(reqs) != 1 || reqs[0].Name != local.Name { + t.Errorf("a change of the Pvc-Mix row enqueued %v, want the local %s", reqs, local.Name) + } + + peers, err := r.listPeerResources(ctx, local) + if err != nil || len(peers) != 2 { + t.Fatalf("peers of pvc-mix = %d, %v; want both rows", len(peers), err) + } + + target := old.DeepCopy() + target.Spec.ResourceDefinitionName, target.Spec.NodeName = "pvc-mix", "node-2" + target.Spec.Flags = nil + + fresh := []blockstoriov1alpha1.Resource{*local, *old} + fresh[1].Spec.Flags = []string{apiv1.ResourceFlagDiskless} + + adoptAuthoritativeFlags(target, fresh) + + if len(target.Spec.Flags) != 1 { + t.Errorf("the flags of the Node-2 row did not reach the node-2 target: %v", target.Spec.Flags) + } + + if missing := peersMissingNodeID(target, []blockstoriov1alpha1.Resource{*local, *old}); len(missing) != 1 || missing[0] != "node-1" { + t.Errorf("peersMissingNodeID = %v, want only the other node", missing) + } +} + +// The local replica itself may be the row written in the registered +// spelling; an edit of the definition still reaches it. +func TestAnEditReachesALocalReplicaInTheRegisteredSpelling(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + local := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-1"}, + } + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}} + + r := &ResourceReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(local, rd).Build(), + Config: Config{NodeName: "node-1"}, + } + + if reqs := r.enqueueResourcesForRD(context.Background(), rd); len(reqs) != 1 || reqs[0].Name != local.Name { + t.Errorf("an edit of pvc-mix enqueued %v, want %s", reqs, local.Name) + } +} + +// An adopted snapshot's entries are backfilled under the snapshot's node +// list, Node-1, and the satellite started as node-1 reads and updates that +// entry rather than adding a second one for itself. +func TestSatelliteReadsAnEntryWrittenUnderTheRegisteredSpelling(t *testing.T) { + t.Parallel() + + entries := []blockstoriov1alpha1.SnapshotPerNodeStatus{{NodeName: "Node-1", Ready: true, CreateTimestamp: 1}} + + if !perNodeStatusReady(entries, "node-1") { + t.Error("perNodeStatusReady missed the Node-1 entry for node-1") + } + + if out := upsertPerNodeStatus(entries, "node-1", 2); len(out) != 1 { + t.Errorf("upsertPerNodeStatus added a second entry: %v", out) + } +} + +// A replica written before the store stored CR names keeps Pvc-Mix in spec; +// the satellite still finds its definition and goes on to apply it. +func TestSatelliteAppliesAnOlderRowOfAMixedCaseDefinition(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + old := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1", Finalizers: []string{SatelliteResourceFinalizer}}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "Node-1"}, + } + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}} + + base := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old, rd).Build() + cli := interceptor.NewClient(base, interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*blockstoriov1alpha1.ResourceList); ok { + return errPastTheDefinition + } + + return c.List(ctx, list, opts...) + }, + }) + + r := &ResourceReconciler{Client: cli, Config: Config{NodeName: "node-1"}} + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: old.Name}}) + if !errors.Is(err, errPastTheDefinition) { + t.Fatalf("reconcile = %v, want it past the definition Get into the peer read", err) + } +} + +// Case variants of a name outside RFC 1123 hash to two CR names and are two +// definitions, the way the store keeps them: My_Res and my_res do not share +// peers, flags or sibling events. +func TestCaseVariantsOutsideRFC1123AreTwoDefinitions(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + mine := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "mine"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "My_Res", NodeName: "node-1"}, + } + otherLocal := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "other-local"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "my_res", NodeName: "node-1", Flags: []string{apiv1.ResourceFlagDiskless}}, + } + otherRemote := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "other-remote"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "my_res", NodeName: "node-2"}, + } + + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mine, otherLocal, otherRemote).Build() + r := &ResourceReconciler{Client: cli, Config: Config{NodeName: "node-1"}} + ctx := context.Background() + + peers, err := r.listPeerResources(ctx, mine) + if err != nil || len(peers) != 1 || peers[0].Name != mine.Name { + t.Fatalf("peers of My_Res = %v, %v; want only its own row", names(peers), err) + } + + target := mine.DeepCopy() + adoptAuthoritativeFlags(target, []blockstoriov1alpha1.Resource{*otherLocal}) + + if len(target.Spec.Flags) != 0 { + t.Errorf("My_Res took the flags of the my_res replica on the same node: %v", target.Spec.Flags) + } + + if reqs := r.enqueueLocalSiblings(ctx, otherRemote); len(reqs) != 1 || reqs[0].Name != otherLocal.Name { + t.Errorf("a change of a my_res row enqueued %v, want only the local my_res replica", reqs) + } +} + +func names(list []blockstoriov1alpha1.Resource) []string { + out := make([]string, 0, len(list)) + for i := range list { + out = append(out, list[i].Name) + } + + return out +} + +// A replica written before the store stored CR names keeps Pvc-Mix in spec; +// its delete still reads the volume numbers off the definition pvc-mix. +func TestADeleteReadsTheVolumesOfTheDefinitionOfAnOlderRow(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "node-1"}, + } + rd := &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0}, {VolumeNumber: 1}}, + }, + } + + r := &ResourceReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(res, rd).Build(), + Config: Config{NodeName: "node-1"}, + } + + nums, err := r.lookupVolumeNumbers(context.Background(), res) + if err != nil || len(nums) != 2 { + t.Errorf("lookupVolumeNumbers = %v, %v; want the two volumes of pvc-mix", nums, err) + } +} diff --git a/pkg/satellite/controllers/old_spelling_name_internal_test.go b/pkg/satellite/controllers/old_spelling_name_internal_test.go new file mode 100644 index 00000000..2b13ed63 --- /dev/null +++ b/pkg/satellite/controllers/old_spelling_name_internal_test.go @@ -0,0 +1,306 @@ +// 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 controllers + +import ( + "slices" + "strings" + "testing" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/satellite" + "github.com/cozystack/blockstor/pkg/storage" + "github.com/cozystack/blockstor/pkg/storage/lvm" +) + +// A replica written before the store kept CR names in spec names its +// definition Pvc-Mix. The satellite lays it down under the definition's CR +// name, pvc-mix, so a snapshot of it is taken under that name whichever +// spelling the snapshot row keeps. +func TestAnOldMixedCaseReplicaIsSnapshottedUnderItsCRName(t *testing.T) { + t.Parallel() + + for _, snapSpelling := range []string{"pvc-mix", "Pvc-Mix"} { + rd := &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + LayerStack: []string{"STORAGE"}, + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0, SizeKib: 4 * 1024}}, + }, + } + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-a"}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: "Pvc-Mix", + NodeName: "node-a", + StoragePool: "thin1", + }, + } + + fx := storage.NewFakeExec() + apply := satellite.NewReconciler(satellite.ReconcilerConfig{ + Providers: map[string]storage.Provider{"thin1": lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)}, + NodeName: "node-a", + }) + + r := newPassphraseTestReconciler(t, rd, res) + r.Config.Exec = fx + r.Config.Apply = apply + + if _, err := r.runApply(t.Context(), res, logr.Discard()); err != nil { + t.Fatalf("runApply: %v", err) + } + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.snap-1", Finalizers: []string{SatelliteSnapshotFinalizer}}, + Spec: blockstoriov1alpha1.SnapshotSpec{ + ResourceDefinitionName: snapSpelling, + SnapshotName: "snap-1", + Nodes: []string{"node-a"}, + TakeSnapshot: true, + }, + } + + cli := fake.NewClientBuilder().WithScheme(r.Client.Scheme()). + WithStatusSubresource(&blockstoriov1alpha1.Snapshot{}).WithObjects(snap).Build() + + snapshots := &SnapshotReconciler{Client: cli, Config: Config{NodeName: "node-a", Apply: apply, Exec: fx}} + if _, err := snapshots.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: snap.Name}}); err != nil { + t.Fatalf("snapshot %s: %v", snapSpelling, err) + } + + var got blockstoriov1alpha1.Snapshot + if err := cli.Get(t.Context(), client.ObjectKey{Name: snap.Name}, &got); err != nil { + t.Fatalf("get snapshot: %v", err) + } + + if slices.Contains(got.Status.Flags, blockstoriov1alpha1.SnapshotStatusFlagFailed) { + t.Errorf("snapshot row %s: FAILED %v", snapSpelling, got.Status) + } + + if !slices.ContainsFunc(fx.CommandLines(), func(c string) bool { + return c == "lvcreate --config devices { filter=['r|^/dev/drbd|','r|^/dev/zd|'] } --snapshot --name pvc-mix_snap-1_00000 vg/pvc-mix_00000" + }) { + t.Errorf("snapshot row %s: no lvcreate of pvc-mix_snap-1_00000 from vg/pvc-mix_00000; commands %v", snapSpelling, fx.CommandLines()) + } + + fx.Expect("lvs --config devices { filter=['r|^/dev/drbd|','r|^/dev/zd|'] } --noheadings -o lv_name vg/pvc-mix_snap-1_00000", + storage.FakeResponse{Stdout: []byte("pvc-mix_snap-1_00000\n")}) + + if err := cli.Delete(t.Context(), &got); err != nil { + t.Fatalf("delete snapshot: %v", err) + } + + if _, err := snapshots.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: snap.Name}}); err != nil { + t.Fatalf("snapshot %s delete: %v", snapSpelling, err) + } + + if !slices.ContainsFunc(fx.CommandLines(), func(c string) bool { + return strings.HasPrefix(c, "lvremove ") && strings.Contains(c, "vg/pvc-mix_snap-1_00000") + }) { + t.Errorf("snapshot row %s: no lvremove of vg/pvc-mix_snap-1_00000; commands %v", snapSpelling, fx.CommandLines()) + } + } +} + +// An old mixed-case replica is torn down under the name it was laid down +// under, by the finalizer delete and by a toggle-disk cancel alike. +func TestAnOldMixedCaseReplicaIsTornDownUnderItsCRName(t *testing.T) { + t.Parallel() + + for name, teardown := range map[string]func(*ResourceReconciler, *blockstoriov1alpha1.Resource) error{ + "delete": func(r *ResourceReconciler, res *blockstoriov1alpha1.Resource) error { + _, err := r.handleDelete(t.Context(), res) + + return err + }, + "toggle-disk cancel": func(r *ResourceReconciler, res *blockstoriov1alpha1.Resource) error { + _, err := r.handleToggleDiskCancel(t.Context(), res, logr.Discard()) + + return err + }, + } { + rd := &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + LayerStack: []string{"STORAGE"}, + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0, SizeKib: 4 * 1024}}, + }, + } + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-a", Finalizers: []string{SatelliteResourceFinalizer}}, + Spec: blockstoriov1alpha1.ResourceSpec{ + ResourceDefinitionName: "Pvc-Mix", + NodeName: "node-a", + StoragePool: "thin1", + }, + } + + fx := storage.NewFakeExec() + r := newPassphraseTestReconciler(t, rd, res) + r.Config.Exec = fx + r.Config.Apply = satellite.NewReconciler(satellite.ReconcilerConfig{ + Providers: map[string]storage.Provider{"thin1": lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)}, + NodeName: "node-a", + }) + + if _, err := r.runApply(t.Context(), res, logr.Discard()); err != nil { + t.Fatalf("%s: runApply: %v", name, err) + } + + fx.Expect("lvs --config devices { filter=['r|^/dev/drbd|','r|^/dev/zd|'] } --noheadings -o lv_name vg/pvc-mix_00000", + storage.FakeResponse{Stdout: []byte("pvc-mix_00000\n")}) + + var live blockstoriov1alpha1.Resource + if err := r.Get(t.Context(), client.ObjectKey{Name: res.Name}, &live); err != nil { + t.Fatalf("%s: get replica: %v", name, err) + } + + if err := teardown(r, &live); err != nil { + t.Fatalf("%s: %v", name, err) + } + + if !slices.ContainsFunc(fx.CommandLines(), func(c string) bool { + return strings.HasPrefix(c, "lvremove ") && strings.Contains(c, "vg/pvc-mix_00000") + }) { + t.Errorf("%s: no lvremove of vg/pvc-mix_00000; commands %v", name, fx.CommandLines()) + } + } +} + +// A snapshot row that keeps the definition as Pvc-Mix suspends, flushes +// and resumes the resource under the CR name it runs under. +func TestAnOldMixedCaseSnapshotRowDrivesTheResourceByItsCRName(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + LayerStack: []string{"STORAGE"}, + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0, SizeKib: 4 * 1024}}, + }, + } + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-a"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-mix", NodeName: "node-a", StoragePool: "thin1"}, + } + + fx := storage.NewFakeExec() + apply := satellite.NewReconciler(satellite.ReconcilerConfig{ + Providers: map[string]storage.Provider{"thin1": lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)}, + Adm: drbd.NewAdm(fx), + Exec: fx, + NodeName: "node-a", + }) + + r := newPassphraseTestReconciler(t, rd, res) + r.Config.Exec = fx + r.Config.Apply = apply + + if _, err := r.runApply(t.Context(), res, logr.Discard()); err != nil { + t.Fatalf("runApply: %v", err) + } + + fx.Expect("lvs --config devices { filter=['r|^/dev/drbd|','r|^/dev/zd|'] } --noheadings --separator | -o lv_path,lv_size --units k --nosuffix vg/pvc-mix_00000", + storage.FakeResponse{Stdout: []byte("/dev/vg/pvc-mix_00000|4096\n")}) + + spec := blockstoriov1alpha1.SnapshotSpec{ + ResourceDefinitionName: "Pvc-Mix", + SnapshotName: "snap-1", + Nodes: []string{"node-a"}, + VolumeDefinitions: []blockstoriov1alpha1.SnapshotVolumeRef{{VolumeNumber: 0}}, + } + + for _, phase := range []struct { + name string + spec func(*blockstoriov1alpha1.SnapshotSpec) + status blockstoriov1alpha1.SnapshotPerNodeStatus + want string + }{ + {"suspend", func(s *blockstoriov1alpha1.SnapshotSpec) { s.SuspendIO = true }, blockstoriov1alpha1.SnapshotPerNodeStatus{NodeName: "node-a"}, "drbdadm suspend-io pvc-mix"}, + {"flush", func(s *blockstoriov1alpha1.SnapshotSpec) { s.SuspendIO, s.TakeSnapshot = true, true }, blockstoriov1alpha1.SnapshotPerNodeStatus{NodeName: "node-a", SuspendIOAcked: true}, "blockdev --flushbufs /dev/vg/pvc-mix_00000"}, + {"resume", func(*blockstoriov1alpha1.SnapshotSpec) {}, blockstoriov1alpha1.SnapshotPerNodeStatus{NodeName: "node-a", Ready: true, CreateTimestamp: 1, SuspendIOAcked: true}, "drbdadm resume-io pvc-mix"}, + } { + s := spec + phase.spec(&s) + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.snap-1", Finalizers: []string{SatelliteSnapshotFinalizer}}, + Spec: s, + Status: blockstoriov1alpha1.SnapshotStatus{NodeStatus: []blockstoriov1alpha1.SnapshotPerNodeStatus{phase.status}}, + } + + cli := fake.NewClientBuilder().WithScheme(r.Client.Scheme()). + WithStatusSubresource(&blockstoriov1alpha1.Snapshot{}).WithObjects(snap).Build() + + snapshots := &SnapshotReconciler{Client: cli, Config: Config{NodeName: "node-a", Apply: apply, Exec: fx}} + if _, err := snapshots.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: snap.Name}}); err != nil { + t.Fatalf("%s: %v", phase.name, err) + } + + if !slices.Contains(fx.CommandLines(), phase.want) { + t.Errorf("%s: missing %q; commands %v", phase.name, phase.want, fx.CommandLines()) + } + } +} + +// The orphan sweepers count an old mixed-case replica as owning the DRBD +// resource and the volumes it runs under, which carry the CR name. +func TestTheSweepersOwnAnOldMixedCaseReplicaByItsCRName(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix"}, + Spec: blockstoriov1alpha1.ResourceDefinitionSpec{ + VolumeDefinitions: []blockstoriov1alpha1.ResourceDefinitionVolume{{VolumeNumber: 0, SizeKib: 4 * 1024}}, + }, + } + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-mix.node-a"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Mix", NodeName: "node-a"}, + } + + cli := newPassphraseTestReconciler(t, rd, res).Client + + volumes, err := (&StorageOrphanSweeperRunnable{Client: cli, NodeName: "node-a"}).listOwnedVolumeRefs(t.Context()) + if err != nil { + t.Fatalf("owned volumes: %v", err) + } + + if _, ok := volumes[ownedKey("pvc-mix", 0)]; !ok { + t.Errorf("owned volumes %v; want pvc-mix volume 0", volumes) + } + + resources, err := (&OrphanSweeperRunnable{Client: cli, NodeName: "node-a"}).listOwnedResourceNames(t.Context()) + if err != nil { + t.Fatalf("owned resources: %v", err) + } + + if _, ok := resources["pvc-mix"]; !ok { + t.Errorf("owned resources %v; want pvc-mix", resources) + } +} diff --git a/pkg/satellite/controllers/physicaldevice.go b/pkg/satellite/controllers/physicaldevice.go index f62d7e48..4cf5fba4 100644 --- a/pkg/satellite/controllers/physicaldevice.go +++ b/pkg/satellite/controllers/physicaldevice.go @@ -37,6 +37,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/satellite" ) @@ -132,7 +133,7 @@ func (r *PhysicalDeviceReconciler) Reconcile(ctx context.Context, req ctrl.Reque return ctrl.Result{}, errors.Wrap(err, "get PhysicalDevice") } - if dev.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode] != r.Config.NodeName { + if !naming.Same(dev.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode], r.Config.NodeName) { // Predicate should have filtered this out; defensive check // for watch-cache resync windows. return ctrl.Result{}, nil @@ -348,7 +349,7 @@ func (r *PhysicalDeviceReconciler) recordAttachedKName(ctx context.Context, dev for i := range pools.Items { p := &pools.Items[i] - if p.Spec.PoolName == poolName && p.Spec.NodeName == r.Config.NodeName { + if naming.Same(p.Spec.PoolName, poolName) && naming.Same(p.Spec.NodeName, r.Config.NodeName) { target = p break @@ -635,8 +636,8 @@ func (r *PhysicalDeviceReconciler) targetPoolExists(ctx context.Context, poolNam } for i := range poolList.Items { - if poolList.Items[i].Spec.PoolName == poolName && - poolList.Items[i].Spec.NodeName == r.Config.NodeName { + if naming.Same(poolList.Items[i].Spec.PoolName, poolName) && + naming.Same(poolList.Items[i].Spec.NodeName, r.Config.NodeName) { return true, nil } } @@ -716,7 +717,7 @@ func physicalDeviceNodePredicate(nodeName string) predicate.Predicate { return false } - return dev.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode] == nodeName + return naming.Same(dev.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode], nodeName) } return predicate.Funcs{ diff --git a/pkg/satellite/controllers/physicaldevice_discovery.go b/pkg/satellite/controllers/physicaldevice_discovery.go index 7c9a53be..44669360 100644 --- a/pkg/satellite/controllers/physicaldevice_discovery.go +++ b/pkg/satellite/controllers/physicaldevice_discovery.go @@ -34,9 +34,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/satellite" "github.com/cozystack/blockstor/pkg/storage" - "github.com/cozystack/blockstor/pkg/store/k8s" "github.com/cozystack/blockstor/pkg/uevent" ) @@ -595,7 +595,7 @@ func (p *PhysicalDeviceDiscoveryRunnable) publishDeviceWithReason(ctx context.Co return "", false } - name := k8s.Name(p.NodeName + "." + stableID) + name := naming.Key(p.NodeName + "." + stableID) // Bug 69: operators type `linstor ps cdp ... /dev/vda` and // match by kernel-name path. The `/dev/disk/by-id/` // path is stable across reboots but useless to humans — and @@ -808,7 +808,7 @@ func (p *PhysicalDeviceDiscoveryRunnable) attachedKNamesForNode(ctx context.Cont for i := range pools.Items { pool := &pools.Items[i] - if pool.Spec.NodeName != p.NodeName { + if !naming.Same(pool.Spec.NodeName, p.NodeName) { continue } diff --git a/pkg/satellite/controllers/rd_needs_drbd_case_test.go b/pkg/satellite/controllers/rd_needs_drbd_case_test.go new file mode 100644 index 00000000..ed196d57 --- /dev/null +++ b/pkg/satellite/controllers/rd_needs_drbd_case_test.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 controllers + +import ( + "testing" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" +) + +// A stack stored in another case still names DRBD; reading it as "no DRBD" +// skipped the DRBD allocation while the apply path brought DRBD up. +func TestRDNeedsDRBDIgnoresCase(t *testing.T) { + t.Parallel() + + rd := &blockstoriov1alpha1.ResourceDefinition{} + rd.Spec.LayerStack = []string{"drbd", "storage"} + + if !rdNeedsDRBD(rd) { + t.Fatal("a lowercase drbd,storage stack read as one without DRBD") + } + + rd.Spec.LayerStack = []string{"storage"} + if rdNeedsDRBD(rd) { + t.Fatal("a storage-only stack read as one with DRBD") + } +} diff --git a/pkg/satellite/controllers/resource.go b/pkg/satellite/controllers/resource.go index 14e1a732..ae198ba4 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -50,6 +50,7 @@ import ( "github.com/cozystack/blockstor/pkg/dispatcher" "github.com/cozystack/blockstor/pkg/effectiveprops" "github.com/cozystack/blockstor/pkg/luks" + "github.com/cozystack/blockstor/pkg/naming" intent "github.com/cozystack/blockstor/pkg/satellite/intent" ) @@ -80,7 +81,7 @@ func enrichNodesWithInternalIP(nodes []blockstoriov1alpha1.Node, corev1Nodes []c for i := range corev1Nodes { for _, addr := range corev1Nodes[i].Status.Addresses { if addr.Type == corev1.NodeInternalIP && addr.Address != "" { - internalIPByName[corev1Nodes[i].Name] = addr.Address + internalIPByName[naming.Key(corev1Nodes[i].Name)] = addr.Address break } @@ -88,7 +89,7 @@ func enrichNodesWithInternalIP(nodes []blockstoriov1alpha1.Node, corev1Nodes []c } for i := range nodes { - internalIP, ok := internalIPByName[nodes[i].Name] + internalIP, ok := internalIPByName[naming.Key(nodes[i].Name)] if !ok || internalIP == "" { continue } @@ -162,7 +163,7 @@ func (r *ResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, errors.Wrap(err, "get Resource") } - if res.Spec.NodeName != r.Config.NodeName { + if !naming.Same(res.Spec.NodeName, r.Config.NodeName) { // Predicate should have filtered this out — defensive // check in case the watch cache is mid-resync. return ctrl.Result{}, nil @@ -420,8 +421,8 @@ func (r *ResourceReconciler) enqueueResourcesForRD(ctx context.Context, obj clie for i := range localList.Items { local := &localList.Items[i] - if local.Spec.ResourceDefinitionName == rd.Name && - local.Spec.NodeName == r.Config.NodeName { + if naming.Same(local.Spec.ResourceDefinitionName, rd.Name) && + naming.Same(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -438,7 +439,7 @@ func (r *ResourceReconciler) enqueueResourcesForRD(ctx context.Context, obj clie func (r *ResourceReconciler) runApply(ctx context.Context, res *blockstoriov1alpha1.Resource, logger logr.Logger) (ctrl.Result, error) { var rd blockstoriov1alpha1.ResourceDefinition - err := r.Get(ctx, client.ObjectKey{Name: res.Spec.ResourceDefinitionName}, &rd) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(res.Spec.ResourceDefinitionName)}, &rd) if err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil @@ -1054,7 +1055,7 @@ func (r *ResourceReconciler) enqueueLocalSiblings(ctx context.Context, obj clien // Ignore events for the local Resource itself — `For` already // drives that path through nodeNamePredicate. - if res.Spec.NodeName == r.Config.NodeName { + if naming.Same(res.Spec.NodeName, r.Config.NodeName) { return nil } @@ -1067,8 +1068,8 @@ func (r *ResourceReconciler) enqueueLocalSiblings(ctx context.Context, obj clien for i := range localList.Items { local := &localList.Items[i] - if local.Spec.ResourceDefinitionName == res.Spec.ResourceDefinitionName && - local.Spec.NodeName == r.Config.NodeName { + if naming.Same(local.Spec.ResourceDefinitionName, res.Spec.ResourceDefinitionName) && + naming.Same(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -1197,7 +1198,7 @@ func (r *ResourceReconciler) listPeerResources(ctx context.Context, target *bloc peers := make([]blockstoriov1alpha1.Resource, 0, len(resList.Items)) for i := range resList.Items { - if resList.Items[i].Spec.ResourceDefinitionName == target.Spec.ResourceDefinitionName { + if naming.Same(resList.Items[i].Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { peers = append(peers, resList.Items[i]) } } @@ -1227,8 +1228,8 @@ func (r *ResourceReconciler) peerReader() client.Reader { // cached flags then stand, which is the pre-fix behaviour. func adoptAuthoritativeFlags(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alpha1.Resource) { for i := range peers { - if peers[i].Spec.NodeName == target.Spec.NodeName && - peers[i].Spec.ResourceDefinitionName == target.Spec.ResourceDefinitionName { + if naming.Same(peers[i].Spec.NodeName, target.Spec.NodeName) && + naming.Same(peers[i].Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { target.Spec.Flags = peers[i].Spec.Flags return @@ -1245,7 +1246,7 @@ func rdNeedsDRBD(rd *blockstoriov1alpha1.ResourceDefinition) bool { return true } - return slices.Contains(stack, "DRBD") + return apiv1.LayerInStack(stack, apiv1.LayerKindDRBD) } // expectedPeerNamesFor returns the peer-node-name set the Bug 342 v3 @@ -1259,7 +1260,7 @@ func expectedPeerNamesFor(target *blockstoriov1alpha1.Resource, peers []blocksto for i := range peers { name := peers[i].Spec.NodeName - if name == "" || name == target.Spec.NodeName { + if name == "" || naming.Same(name, target.Spec.NodeName) { continue } @@ -1407,7 +1408,7 @@ func peersMissingNodeID(target *blockstoriov1alpha1.Resource, peers []blockstori for i := range peers { peer := &peers[i] - if peer.Spec.NodeName == target.Spec.NodeName { + if naming.Same(peer.Spec.NodeName, target.Spec.NodeName) { continue } @@ -1475,7 +1476,7 @@ func (r *ResourceReconciler) handleDelete(ctx context.Context, res *blockstoriov } req := &intent.DeleteResourceRequest{ - Name: res.Spec.ResourceDefinitionName, + Name: naming.Key(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } @@ -1574,7 +1575,7 @@ func (r *ResourceReconciler) deleteReader() client.Reader { func (r *ResourceReconciler) lookupVolumeNumbers(ctx context.Context, res *blockstoriov1alpha1.Resource) ([]int32, error) { var rd blockstoriov1alpha1.ResourceDefinition - err := r.Get(ctx, client.ObjectKey{Name: res.Spec.ResourceDefinitionName}, &rd) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(res.Spec.ResourceDefinitionName)}, &rd) if err == nil { out := make([]int32, 0, len(rd.Spec.VolumeDefinitions)) for i := range rd.Spec.VolumeDefinitions { @@ -1634,11 +1635,11 @@ func volumeNumbersFromStatus(res *blockstoriov1alpha1.Resource) []int32 { func nodeNamePredicate(nodeName string) predicate.Predicate { matches := func(obj client.Object) bool { if r, ok := obj.(*blockstoriov1alpha1.Resource); ok { - return r.Spec.NodeName == nodeName + return naming.Same(r.Spec.NodeName, nodeName) } if sp, ok := obj.(*blockstoriov1alpha1.StoragePool); ok { - return sp.Spec.NodeName == nodeName + return naming.Same(sp.Spec.NodeName, nodeName) } return false diff --git a/pkg/satellite/controllers/resource_toggle_disk.go b/pkg/satellite/controllers/resource_toggle_disk.go index 2ecc15a3..e128679b 100644 --- a/pkg/satellite/controllers/resource_toggle_disk.go +++ b/pkg/satellite/controllers/resource_toggle_disk.go @@ -33,6 +33,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/naming" intent "github.com/cozystack/blockstor/pkg/satellite/intent" ) @@ -212,7 +213,7 @@ func (r *ResourceReconciler) handleToggleDiskCancel(ctx context.Context, res *bl } req := &intent.DeleteResourceRequest{ - Name: res.Spec.ResourceDefinitionName, + Name: naming.Key(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } diff --git a/pkg/satellite/controllers/skip_disk_clearer.go b/pkg/satellite/controllers/skip_disk_clearer.go index 6974fda8..39173f7d 100644 --- a/pkg/satellite/controllers/skip_disk_clearer.go +++ b/pkg/satellite/controllers/skip_disk_clearer.go @@ -27,7 +27,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // SSA unstructured field-name constants used by both writeSkipDiskProp @@ -94,7 +93,10 @@ func (s *SkipDiskClearer) ClearSkipDisk(ctx context.Context, resourceName string return nil } - name := k8s.Name(resourceName + "." + s.NodeName) + name, err := replicaCRName(ctx, s.Client, resourceName, s.NodeName) + if err != nil { + return err + } // Read the existing Resource to carry the immutable required // scalars (`resourceDefinitionName`, `nodeName`) on the SSA apply @@ -105,7 +107,7 @@ func (s *SkipDiskClearer) ClearSkipDisk(ctx context.Context, resourceName string // clear; silently no-op. var existing blockstoriov1alpha1.Resource - err := s.Client.Get(ctx, client.ObjectKey{Name: name}, &existing) + err = s.Client.Get(ctx, client.ObjectKey{Name: name}, &existing) if err != nil { if apierrors.IsNotFound(err) { return nil diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index 910bba87..190fe59b 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" intent "github.com/cozystack/blockstor/pkg/satellite/intent" ) @@ -87,7 +88,7 @@ func (r *SnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, errors.Wrap(err, "get Snapshot") } - if !slices.Contains(snap.Spec.Nodes, r.Config.NodeName) { + if !slices.ContainsFunc(snap.Spec.Nodes, func(n string) bool { return naming.Same(n, r.Config.NodeName) }) { return ctrl.Result{}, nil } @@ -211,7 +212,7 @@ func (r *SnapshotReconciler) handleCreate(ctx context.Context, snap *blockstorio // suspend stamps the per-node Failed=true so the controller-side // orchestrator drains the siblings. func (r *SnapshotReconciler) handleSuspendPhase(ctx context.Context, snap *blockstoriov1alpha1.Snapshot) (ctrl.Result, error) { - err := r.Config.Apply.SuspendResource(ctx, snap.Spec.ResourceDefinitionName) + err := r.Config.Apply.SuspendResource(ctx, naming.Key(snap.Spec.ResourceDefinitionName)) if err != nil { log.FromContext(ctx).Info("SuspendResource failed", "snapshot", snap.Spec.SnapshotName, "error", err.Error()) @@ -246,7 +247,7 @@ func (r *SnapshotReconciler) handleSuspendPhase(ctx context.Context, snap *block // clearing the ack so the orchestrator keeps poking us until // the kernel actually accepts the unfreeze. func (r *SnapshotReconciler) handleResumePhase(ctx context.Context, snap *blockstoriov1alpha1.Snapshot) (ctrl.Result, error) { - err := r.Config.Apply.ResumeResource(ctx, snap.Spec.ResourceDefinitionName) + err := r.Config.Apply.ResumeResource(ctx, naming.Key(snap.Spec.ResourceDefinitionName)) if err != nil { log.FromContext(ctx).Info("ResumeResource failed", "snapshot", snap.Spec.SnapshotName, "error", err.Error()) @@ -271,7 +272,7 @@ func (r *SnapshotReconciler) handleResumePhase(ctx context.Context, snap *blocks // step runs. func (r *SnapshotReconciler) handleTakeSnapshotPhase(ctx context.Context, snap *blockstoriov1alpha1.Snapshot) (ctrl.Result, error) { req := &intent.CreateSnapshotRequest{ - ResourceName: snap.Spec.ResourceDefinitionName, + ResourceName: naming.Key(snap.Spec.ResourceDefinitionName), SnapshotName: snap.Spec.SnapshotName, } @@ -298,7 +299,7 @@ func (r *SnapshotReconciler) handleTakeSnapshotPhase(ctx context.Context, snap * // flush, and a stripped-down satellite environment (kernel // without BLKFLSBUF, hostpath sandboxing) shouldn't block the // snapshot from running. - err := r.Config.Apply.FlushBackingDevices(ctx, snap.Spec.ResourceDefinitionName, req.VolumeNumbers) + err := r.Config.Apply.FlushBackingDevices(ctx, naming.Key(snap.Spec.ResourceDefinitionName), req.VolumeNumbers) if err != nil { // FlushBackingDevices itself returns nil on best-effort // per-device failures; a non-nil return signals a hard @@ -358,7 +359,7 @@ func (r *SnapshotReconciler) handleTakeSnapshotPhase(ctx context.Context, snap * // Status subresource. func perNodeStatusSuspendIOAcked(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, nodeName string) bool { for i := range entries { - if entries[i].NodeName != nodeName { + if !naming.Same(entries[i].NodeName, nodeName) { continue } @@ -527,12 +528,15 @@ func upsertPerNodeStatusInPlace( nodeName string, mutate func(*blockstoriov1alpha1.SnapshotPerNodeStatus) bool, ) bool { - for i := range *entries { - if (*entries)[i].NodeName != nodeName { - continue + collapsed, mine, folded := collapseNodeStatus(*entries, nodeName) + if mine >= 0 { + if !mutate(&collapsed[mine]) && !folded { + return false } - return mutate(&(*entries)[i]) + *entries = collapsed + + return true } fresh := blockstoriov1alpha1.SnapshotPerNodeStatus{NodeName: nodeName} @@ -545,6 +549,41 @@ func upsertPerNodeStatusInPlace( return true } +// collapseNodeStatus folds every entry for one node, in whatever spelling +// it was written, into the first of them. Entries written before under +// Node-1 and node-1 are one node's: a writer that updated only the first +// and a reader that kept only the last would never meet. Returns the +// entries, the folded entry's index (-1 when the node has none), and +// whether any alias was folded away. +func collapseNodeStatus( + entries []blockstoriov1alpha1.SnapshotPerNodeStatus, nodeName string, +) ([]blockstoriov1alpha1.SnapshotPerNodeStatus, int, bool) { + out := make([]blockstoriov1alpha1.SnapshotPerNodeStatus, 0, len(entries)) + mine := -1 + folded := false + + for i := range entries { + entry := entries[i] + + switch { + case !naming.Same(entry.NodeName, nodeName): + out = append(out, entry) + case mine < 0: + mine = len(out) + out = append(out, entry) + default: + kept := &out[mine] + kept.Ready = kept.Ready || entry.Ready + kept.SuspendIOAcked = kept.SuspendIOAcked || entry.SuspendIOAcked + kept.Failed = kept.Failed || entry.Failed + kept.CreateTimestamp = max(kept.CreateTimestamp, entry.CreateTimestamp) + folded = true + } + } + + return out, mine, folded +} + // stampSnapshotPerNodeReady upserts our node's entry in // Status.NodeStatus with Ready=true and the satellite-reported // CreateTimestamp. This is the missing half of the Bug 106 fix: @@ -625,7 +664,7 @@ const snapshotStatusUpdateRetries = 5 // non-zero stamp keeps the wire shape correct. func perNodeStatusReady(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, nodeName string) bool { for i := range entries { - if entries[i].NodeName != nodeName { + if !naming.Same(entries[i].NodeName, nodeName) { continue } @@ -644,34 +683,19 @@ func perNodeStatusReady(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, nod // (otherwise the controller-side orchestrator would see the ack // drop and mis-classify the snapshot as still mid-suspend). func upsertPerNodeStatus(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, nodeName string, createTimestamp int64) []blockstoriov1alpha1.SnapshotPerNodeStatus { - out := make([]blockstoriov1alpha1.SnapshotPerNodeStatus, 0, len(entries)+1) - replaced := false - - for i := range entries { - if entries[i].NodeName == nodeName { - merged := entries[i] - merged.NodeName = nodeName - merged.Ready = true - merged.CreateTimestamp = createTimestamp - - out = append(out, merged) - - replaced = true - - continue - } - - out = append(out, entries[i]) - } - - if !replaced { - out = append(out, blockstoriov1alpha1.SnapshotPerNodeStatus{ + out, mine, _ := collapseNodeStatus(entries, nodeName) + if mine < 0 { + return append(out, blockstoriov1alpha1.SnapshotPerNodeStatus{ NodeName: nodeName, Ready: true, CreateTimestamp: createTimestamp, }) } + out[mine].NodeName = nodeName + out[mine].Ready = true + out[mine].CreateTimestamp = createTimestamp + return out } @@ -697,7 +721,7 @@ func (r *SnapshotReconciler) handleDelete(ctx context.Context, snap *blockstorio } req := &intent.DeleteSnapshotRequest{ - ResourceName: snap.Spec.ResourceDefinitionName, + ResourceName: naming.Key(snap.Spec.ResourceDefinitionName), SnapshotName: snap.Spec.SnapshotName, } @@ -737,7 +761,7 @@ func snapshotNodePredicate(nodeName string) predicate.Predicate { return false } - return slices.Contains(snap.Spec.Nodes, nodeName) + return slices.ContainsFunc(snap.Spec.Nodes, func(n string) bool { return naming.Same(n, nodeName) }) } return predicate.Funcs{ diff --git a/pkg/satellite/controllers/snapshot_fetcher.go b/pkg/satellite/controllers/snapshot_fetcher.go index c5969a99..31007ac0 100644 --- a/pkg/satellite/controllers/snapshot_fetcher.go +++ b/pkg/satellite/controllers/snapshot_fetcher.go @@ -27,9 +27,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/satellite/stream" "github.com/cozystack/blockstor/pkg/storage" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // SnapshotFetcher implements satellite.CrossNodeFetcher. Looks up @@ -73,7 +73,12 @@ func (f *SnapshotFetcher) Fetch(ctx context.Context, srcRD, snapName string, vol var snap blockstoriov1alpha1.Snapshot - err := f.Client.Get(ctx, client.ObjectKey{Name: k8s.Name(srcRD + "." + snapName)}, &snap) + name, err := snapshotCRName(ctx, f.Client, srcRD, snapName) + if err != nil { + return nil, "", err + } + + err = f.Client.Get(ctx, client.ObjectKey{Name: name}, &snap) if err != nil { if apierrors.IsNotFound(err) { return nil, "", errors.Wrapf(storage.ErrNotFound, "snapshot CRD %s/%s", srcRD, snapName) @@ -89,7 +94,7 @@ func (f *SnapshotFetcher) Fetch(ctx context.Context, srcRD, snapName string, vol var lastErr error for _, peer := range snap.Spec.Nodes { - if peer == f.NodeName { + if naming.Same(peer, f.NodeName) { continue } @@ -126,7 +131,7 @@ func (f *SnapshotFetcher) Fetch(ctx context.Context, srcRD, snapName string, vol func (f *SnapshotFetcher) resolvePeerAddr(ctx context.Context, peerNode string) (string, error) { var node blockstoriov1alpha1.Node - err := f.Client.Get(ctx, client.ObjectKey{Name: peerNode}, &node) + err := f.Client.Get(ctx, client.ObjectKey{Name: naming.Key(peerNode)}, &node) if err != nil { return "", errors.Wrapf(err, "get node CRD %s", peerNode) } diff --git a/pkg/satellite/controllers/snapshot_fetcher_test.go b/pkg/satellite/controllers/snapshot_fetcher_test.go index 2be164ae..79cbc932 100644 --- a/pkg/satellite/controllers/snapshot_fetcher_test.go +++ b/pkg/satellite/controllers/snapshot_fetcher_test.go @@ -26,10 +26,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/satellite/controllers" "github.com/cozystack/blockstor/pkg/satellite/stream" "github.com/cozystack/blockstor/pkg/storage" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // A satellite with no stream TLS configured cannot fetch from a peer. The @@ -40,7 +40,7 @@ func TestSnapshotFetcherWithTheStreamOffFallsBackToAResync(t *testing.T) { t.Parallel() snap := &blockstoriov1alpha1.Snapshot{ - ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("rd.snap")}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key("rd.snap")}, Spec: blockstoriov1alpha1.SnapshotSpec{ ResourceDefinitionName: "rd", SnapshotName: "snap", diff --git a/pkg/satellite/controllers/snapshot_node_spelling_test.go b/pkg/satellite/controllers/snapshot_node_spelling_test.go new file mode 100644 index 00000000..b2c8f37c --- /dev/null +++ b/pkg/satellite/controllers/snapshot_node_spelling_test.go @@ -0,0 +1,143 @@ +// 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 controllers + +import ( + "slices" + "testing" + + 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/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" +) + +// A snapshot's node list carries the registered spelling, Node-1, and the +// satellite started as node-1 takes it, both in the event filter and in the +// reconcile. +func TestSatelliteTakesASnapshotListingItsNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + pred := snapshotNodePredicate("node-1") + + for spelling, want := range map[string]bool{"Node-1": true, "node-1": true, "node-2": false} { + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "rd.snap"}, + Spec: blockstoriov1alpha1.SnapshotSpec{Nodes: []string{spelling}}, + } + + if got := pred.Create(event.CreateEvent{Object: snap}); got != want { + t.Errorf("predicate for Nodes [%s] = %v, want %v", spelling, got, want) + } + + if got := slices.ContainsFunc(snap.Spec.Nodes, func(n string) bool { return naming.Same(n, "node-1") }); got != want { + t.Errorf("slices.ContainsFunc([%s], func(n string) bool { return naming.Same(n, node-1) }) = %v, want %v", spelling, got, want) + } + } +} + +// The fetcher reads a peer's address from the Node CR, named from the +// folded name, while the snapshot lists the peer as Node-2. +func TestFetcherResolvesAPeerListedInTheRegisteredSpelling(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + peer := &blockstoriov1alpha1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Spec: blockstoriov1alpha1.NodeSpec{ + NetInterfaces: []blockstoriov1alpha1.NodeNetInterface{{Name: "default", Address: "192.0.2.2"}}, + }, + } + + f := &SnapshotFetcher{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(peer).Build(), NodeName: "node-1"} + + addr, err := f.resolvePeerAddr(t.Context(), "Node-2") + if err != nil { + t.Fatalf("resolvePeerAddr(Node-2): %v", err) + } + + if addr == "" { + t.Error("resolvePeerAddr(Node-2) gave no address") + } +} + +// Status written before under Node-1 and node-1 holds two entries for one +// node. The acknowledgment folds them into one, so a reader that keeps any +// of them, the first or the last, sees it. +func TestSuspendAckFoldsTheNodesStatusEntries(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := blockstoriov1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "rd.snap"}, + Spec: blockstoriov1alpha1.SnapshotSpec{Nodes: []string{"Node-1"}, SuspendIO: true}, + Status: blockstoriov1alpha1.SnapshotStatus{NodeStatus: []blockstoriov1alpha1.SnapshotPerNodeStatus{ + {NodeName: "Node-1"}, {NodeName: "node-2"}, {NodeName: "node-1", CreateTimestamp: 7}, + }}, + } + + cli := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&blockstoriov1alpha1.Snapshot{}).WithObjects(snap).Build() + r := &SnapshotReconciler{Client: cli, Config: Config{NodeName: "Node-1"}} + + if err := r.stampSnapshotPerNodeSuspendAcked(t.Context(), snap); err != nil { + t.Fatalf("stamp: %v", err) + } + + var got blockstoriov1alpha1.Snapshot + if err := cli.Get(t.Context(), client.ObjectKeyFromObject(snap), &got); err != nil { + t.Fatalf("get: %v", err) + } + + var mine []blockstoriov1alpha1.SnapshotPerNodeStatus + + for _, e := range got.Status.NodeStatus { + if naming.Same(e.NodeName, "node-1") { + mine = append(mine, e) + } + } + + if len(mine) != 1 || !mine[0].SuspendIOAcked || mine[0].CreateTimestamp != 7 || len(got.Status.NodeStatus) != 2 { + t.Errorf("status %+v; want one acknowledged entry for node-1 keeping its timestamp, beside node-2's", got.Status.NodeStatus) + } +} + +func TestReadyStampFoldsTheNodesStatusEntries(t *testing.T) { + t.Parallel() + + got := upsertPerNodeStatus([]blockstoriov1alpha1.SnapshotPerNodeStatus{ + {NodeName: "Node-1"}, {NodeName: "node-1", SuspendIOAcked: true}, + }, "node-1", 9) + + if len(got) != 1 || !got[0].Ready || got[0].CreateTimestamp != 9 || !got[0].SuspendIOAcked { + t.Errorf("status %+v; want one ready entry for node-1 keeping its acknowledgment", got) + } +} diff --git a/pkg/satellite/controllers/snapshot_test.go b/pkg/satellite/controllers/snapshot_test.go index 762b9670..5522273a 100644 --- a/pkg/satellite/controllers/snapshot_test.go +++ b/pkg/satellite/controllers/snapshot_test.go @@ -848,3 +848,45 @@ func TestSnapshotReconcileNodeStatusIdempotentRestamp(t *testing.T) { got.Status.NodeStatus[0].CreateTimestamp, seedTimestamp) } } + +// A snapshot lists its node in the registered spelling, Node-1, and the +// satellite started as node-1 takes it: the reconcile stamps its finalizer +// rather than passing the snapshot by as another node's. +func TestSnapshotReconcileTakesItsNodeInAnotherSpelling(t *testing.T) { + t.Parallel() + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-1.snap-1"}, + Spec: blockstoriov1alpha1.SnapshotSpec{ + ResourceDefinitionName: "pvc-1", + SnapshotName: "snap-1", + Nodes: []string{"Node-1"}, + }, + } + + cli := fake.NewClientBuilder().WithScheme(newSnapshotScheme(t)).WithObjects(snap).Build() + + reconciler := &controllers.SnapshotReconciler{ + Client: cli, + Config: controllers.Config{ + NodeName: "node-1", + Apply: satellite.NewReconciler(satellite.ReconcilerConfig{Providers: map[string]storage.Provider{}}), + Exec: storage.NewFakeExec(), + }, + } + + if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "pvc-1.snap-1"}, + }); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + var got blockstoriov1alpha1.Snapshot + if err := cli.Get(context.Background(), client.ObjectKey{Name: "pvc-1.snap-1"}, &got); err != nil { + t.Fatalf("Get: %v", err) + } + + if !slices.Contains(got.Finalizers, controllers.SatelliteSnapshotFinalizer) { + t.Errorf("node-1 passed by a snapshot listing Node-1: finalizers %v", got.Finalizers) + } +} diff --git a/pkg/satellite/controllers/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index 5496d961..b4ac3aaf 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/storage" ) @@ -341,7 +342,7 @@ func (s *StorageOrphanSweeperRunnable) reapIfOrphan( func (s *StorageOrphanSweeperRunnable) shouldSkip(ctx context.Context) (bool, error) { var node blockstoriov1alpha1.Node - err := s.Client.Get(ctx, client.ObjectKey{Name: s.NodeName}, &node) + err := s.Client.Get(ctx, client.ObjectKey{Name: naming.Key(s.NodeName)}, &node) if err != nil { return false, errors.Wrap(err, "get Node") } @@ -379,18 +380,22 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) for i := range resList.Items { r := &resList.Items[i] - if r.Spec.NodeName != s.NodeName { + if !naming.Same(r.Spec.NodeName, s.NodeName) { continue } - vols, ok := rdToVolumes[r.Spec.ResourceDefinitionName] + // The volumes are laid down under the definition's CR name, + // whatever spelling an older row keeps in spec. + rdName := naming.Key(r.Spec.ResourceDefinitionName) + + vols, ok := rdToVolumes[rdName] if !ok { - vols = s.lookupRDVolumes(ctx, r.Spec.ResourceDefinitionName) - rdToVolumes[r.Spec.ResourceDefinitionName] = vols + vols = s.lookupRDVolumes(ctx, rdName) + rdToVolumes[rdName] = vols } for _, vn := range vols { - out[ownedKey(r.Spec.ResourceDefinitionName, vn)] = struct{}{} + out[ownedKey(rdName, vn)] = struct{}{} } // Edge case: RD already gone but the Resource CRD is still @@ -398,7 +403,7 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) // keyed only by resource name so the sweeper still // recognises the in-flight delete and doesn't race. if len(vols) == 0 { - out[ownedKey(r.Spec.ResourceDefinitionName, -1)] = struct{}{} + out[ownedKey(rdName, -1)] = struct{}{} } } @@ -412,7 +417,7 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) func (s *StorageOrphanSweeperRunnable) lookupRDVolumes(ctx context.Context, rdName string) []int32 { var rd blockstoriov1alpha1.ResourceDefinition - err := s.Client.Get(ctx, client.ObjectKey{Name: rdName}, &rd) + err := s.Client.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd) if err != nil { return nil } diff --git a/pkg/satellite/controllers/storagepool.go b/pkg/satellite/controllers/storagepool.go index 36e0ebd6..e5c06d6e 100644 --- a/pkg/satellite/controllers/storagepool.go +++ b/pkg/satellite/controllers/storagepool.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/satellite" "github.com/cozystack/blockstor/pkg/storage" ) @@ -80,7 +81,7 @@ func (r *StoragePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, errors.Wrap(err, "get StoragePool") } - if pool.Spec.NodeName != r.Config.NodeName { + if !naming.Same(pool.Spec.NodeName, r.Config.NodeName) { return ctrl.Result{}, nil } diff --git a/pkg/satellite/controllers/sweeper.go b/pkg/satellite/controllers/sweeper.go index 9497b49c..3d5ffba3 100644 --- a/pkg/satellite/controllers/sweeper.go +++ b/pkg/satellite/controllers/sweeper.go @@ -32,6 +32,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" "github.com/cozystack/blockstor/pkg/drbd" + "github.com/cozystack/blockstor/pkg/naming" ) // SweeperPeriod is the default cadence at which the orphan sweeper @@ -385,7 +386,7 @@ const ( // callers that don't plumb StateDir (the legacy unit-test fixture) // keep the pre-fix CRD-only behaviour. func classifyOrphan(rsc string, owned map[string]struct{}, rdAges map[string]time.Time, now time.Time, grace time.Duration, stateDir string) (sweepDecision, time.Duration) { - if _, ok := owned[rsc]; ok { + if _, ok := owned[naming.Key(rsc)]; ok { return sweepKeep, 0 } @@ -397,7 +398,7 @@ func classifyOrphan(rsc string, owned map[string]struct{}, rdAges map[string]tim return sweepTearDown, 0 } - anchor, ok := rdAges[rsc] + anchor, ok := rdAges[naming.Key(rsc)] if !ok { return sweepTearDown, 0 } @@ -571,7 +572,7 @@ func (s *OrphanSweeperRunnable) callSetupDown(ctx context.Context, resource stri func (s *OrphanSweeperRunnable) shouldSkip(ctx context.Context) (bool, error) { var node blockstoriov1alpha1.Node - err := s.Client.Get(ctx, client.ObjectKey{Name: s.NodeName}, &node) + err := s.Client.Get(ctx, client.ObjectKey{Name: naming.Key(s.NodeName)}, &node) if err != nil { return false, errors.Wrap(err, "get Node") } @@ -601,11 +602,11 @@ func (s *OrphanSweeperRunnable) listOwnedResourceNames(ctx context.Context) (map for i := range list.Items { r := &list.Items[i] - if r.Spec.NodeName != s.NodeName { + if !naming.Same(r.Spec.NodeName, s.NodeName) { continue } - out[r.Spec.ResourceDefinitionName] = struct{}{} + out[naming.Key(r.Spec.ResourceDefinitionName)] = struct{}{} } return out, nil @@ -644,7 +645,7 @@ func (s *OrphanSweeperRunnable) listResourceDefinitionAges(ctx context.Context) anchor = rd.DeletionTimestamp.Time } - out[rd.Name] = anchor + out[naming.Key(rd.Name)] = anchor } return out, nil diff --git a/pkg/satellite/controllers/sweeper_resfile.go b/pkg/satellite/controllers/sweeper_resfile.go index 09ad096f..6d575a65 100644 --- a/pkg/satellite/controllers/sweeper_resfile.go +++ b/pkg/satellite/controllers/sweeper_resfile.go @@ -27,6 +27,8 @@ import ( "github.com/cockroachdb/errors" "github.com/go-logr/logr" + + "github.com/cozystack/blockstor/pkg/naming" ) // resFileSuffix is the on-disk suffix the satellite reconciler renders @@ -98,13 +100,13 @@ func orphanResForRemoval( continue } - if _, ok := owned[rd]; ok { + if _, ok := owned[naming.Key(rd)]; ok { // Live resource this node hosts — never GC its config. continue } if grace > 0 { - if anchor, ok := rdAges[rd]; ok && now.Sub(anchor) < grace { + if anchor, ok := rdAges[naming.Key(rd)]; ok && now.Sub(anchor) < grace { // RD was created/deleted inside the grace window — a // create-fanout or delete-in-progress race, not a // genuine orphan. Defer to a later cycle. diff --git a/pkg/satellite/createmd_test.go b/pkg/satellite/createmd_test.go index 40af624e..1ed0782a 100644 --- a/pkg/satellite/createmd_test.go +++ b/pkg/satellite/createmd_test.go @@ -183,7 +183,9 @@ type fakeMetadataStamperInternal struct { calls []string } -func (f *fakeMetadataStamperInternal) StampMetadataCreated(_ context.Context, resourceName string) error { +func (f *fakeMetadataStamperInternal) StampMetadataCreated(_ context.Context, rdName, nodeName string) error { + resourceName := rdName + "." + nodeName + f.calls = append(f.calls, resourceName) return nil } diff --git a/pkg/satellite/reconciler.go b/pkg/satellite/reconciler.go index 83d2d09d..9dc2db49 100644 --- a/pkg/satellite/reconciler.go +++ b/pkg/satellite/reconciler.go @@ -37,6 +37,7 @@ import ( "github.com/cozystack/blockstor/pkg/drbd" "github.com/cozystack/blockstor/pkg/luks" + "github.com/cozystack/blockstor/pkg/naming" intent "github.com/cozystack/blockstor/pkg/satellite/intent" "github.com/cozystack/blockstor/pkg/storage" ) @@ -166,11 +167,13 @@ type CrossNodeFetcher interface { // a controller-runtime client dependency. Phase 11.3 Stage 1. type MetadataCreatedStamper interface { // StampMetadataCreated SSA-patches a `MetadataCreated=True` - // Condition onto Resource .Status.Conditions. - // Idempotent — repeat calls converge on the same Condition - // shape (LastTransitionTime moves forward on apiserver-side - // transition only, not on every patch). - StampMetadataCreated(ctx context.Context, resourceName string) error + // Condition onto the Resource CR of the replica of resourceName + // on nodeName, both as the apply chain knows them (the + // definition's and the node's CR names); the stamper maps them + // to the CR the store named. Idempotent — repeat calls converge + // on the same Condition shape (LastTransitionTime moves forward + // on apiserver-side transition only, not on every patch). + StampMetadataCreated(ctx context.Context, resourceName, nodeName string) error } // FilesystemFormattedStamper abstracts the "stamp the @@ -186,7 +189,7 @@ type FilesystemFormattedStamper interface { // converge on the same Condition shape (LastTransitionTime // moves forward on apiserver-side transition only, not on // every patch). - StampFilesystemFormatted(ctx context.Context, resourceName string) error + StampFilesystemFormatted(ctx context.Context, resourceName, nodeName string) error // StampFilesystemObserved SSA-patches the same // `FilesystemFormatted=True` Condition with a distinct @@ -199,7 +202,7 @@ type FilesystemFormattedStamper interface { // differ — so the listMap merge on `type=FilesystemFormatted` // keeps both writers cleanly sharing one Condition entry // without disturbing `.status.volumes` ownership. - StampFilesystemObserved(ctx context.Context, resourceName string) error + StampFilesystemObserved(ctx context.Context, resourceName, nodeName string) error } // SkipDiskClearer abstracts the "release the satellite's SSA claim @@ -1918,17 +1921,19 @@ func computeRemovedPeers(resPath string, dr *intent.DesiredResource, localNode s return nil } + // The file names a peer as it was rendered then, the intent as its row + // spells it now; a peer whose spelling changed has not left. want := make(map[string]struct{}, len(dr.GetPeers())+1) - want[localNode] = struct{}{} + want[naming.Key(localNode)] = struct{}{} for _, p := range dr.GetPeerNames() { - want[p] = struct{}{} + want[naming.Key(p)] = struct{}{} } var removed []string for _, p := range old { - if _, keep := want[p]; !keep { + if _, keep := want[naming.Key(p)]; !keep { removed = append(removed, p) } } @@ -3611,12 +3616,10 @@ func (r *Reconciler) ensureMetadata(ctx context.Context, dr *intent.DesiredResou // stamp attempt, polluting ERROR logs since Phase 11.3 // Stage 1 (#489). Best-effort tolerated (file marker is the // source of truth) so no functional regression, just noise. - resourceCRDName := dr.GetName() + "." + dr.GetNodeName() - - stampErr := r.cfg.MetadataCreatedStamper.StampMetadataCreated(ctx, resourceCRDName) + stampErr := r.cfg.MetadataCreatedStamper.StampMetadataCreated(ctx, dr.GetName(), dr.GetNodeName()) if stampErr != nil { log.FromContext(ctx).Error(stampErr, "stamp MetadataCreated Condition; will retry next reconcile", - "resource", resourceCRDName) + "resource", dr.GetName(), "node", dr.GetNodeName()) } } @@ -4411,12 +4414,10 @@ func (r *Reconciler) runAutoMkfs(ctx context.Context, dr *intent.DesiredResource // Mirror the Bug 344 fix in maybeStampMetadata's caller. // Best-effort tolerated (file marker is the source of truth) // so no functional regression on a transient apiserver hiccup. - resourceCRDName := dr.GetName() + "." + dr.GetNodeName() - - stampErr := r.cfg.FilesystemFormattedStamper.StampFilesystemFormatted(ctx, resourceCRDName) + stampErr := r.cfg.FilesystemFormattedStamper.StampFilesystemFormatted(ctx, dr.GetName(), dr.GetNodeName()) if stampErr != nil { log.FromContext(ctx).Error(stampErr, "stamp FilesystemFormatted Condition; will retry next reconcile", - "resource", resourceCRDName) + "resource", dr.GetName(), "node", dr.GetNodeName()) } } @@ -5696,7 +5697,7 @@ func buildResFile(dr *intent.DesiredResource, localNode, localAddr string, devic Disk: sections.Disk, Handlers: sections.Handlers, PeerDevice: sections.PeerDevice, - Connections: buildResConnections(dr), + Connections: buildResConnections(dr, hosts), }) if err != nil { return "", errors.Wrap(err, "drbd.Build") @@ -5710,7 +5711,12 @@ func buildResFile(dr *intent.DesiredResource, localNode, localAddr string, devic // renderer's drbd.ResourceConnection shape. Empty input returns nil // — the renderer then falls back to the default single-host-pair // connection block. -func buildResConnections(dr *intent.DesiredResource) []drbd.ResourceConnection { +// +// A connection names its nodes as the request spelled them, Node-1 say, +// while the hosts carry the replicas' spec, which stores the node's CR +// name, node-1. Each node is written in the spelling of the host it names, +// so the renderer pairs the paths with their hosts. +func buildResConnections(dr *intent.DesiredResource, hosts []drbd.Host) []drbd.ResourceConnection { src := dr.GetConnections() if len(src) == 0 { return nil @@ -5730,8 +5736,8 @@ func buildResConnections(dr *intent.DesiredResource) []drbd.ResourceConnection { } out = append(out, drbd.ResourceConnection{ - NodeA: conn.NodeA, - NodeB: conn.NodeB, + NodeA: hostSpelling(hosts, conn.NodeA), + NodeB: hostSpelling(hosts, conn.NodeB), Paths: paths, }) } @@ -5739,6 +5745,18 @@ func buildResConnections(dr *intent.DesiredResource) []drbd.ResourceConnection { return out } +// hostSpelling answers the name of the host that node names, or node as it +// is when no host is that node. +func hostSpelling(hosts []drbd.Host, node string) string { + for i := range hosts { + if naming.Same(hosts[i].NodeName, node) { + return hosts[i].NodeName + } + } + + return node +} + // buildResVolumes turns the per-RD DesiredVolumes into the // drbd.Volume slice the .res renderer consumes. Pulled out of // buildResFile to keep that function under the funlen budget. diff --git a/pkg/satellite/reconciler_drbd_test.go b/pkg/satellite/reconciler_drbd_test.go index b0ca03a9..f217508a 100644 --- a/pkg/satellite/reconciler_drbd_test.go +++ b/pkg/satellite/reconciler_drbd_test.go @@ -7189,7 +7189,9 @@ type fakeFilesystemStamper struct { stamps func(string) bool // optional: return true to fail the call } -func (f *fakeFilesystemStamper) StampFilesystemFormatted(_ context.Context, resourceName string) error { +func (f *fakeFilesystemStamper) StampFilesystemFormatted(_ context.Context, rdName, nodeName string) error { + resourceName := rdName + "." + nodeName + f.mu.Lock() defer f.mu.Unlock() @@ -7202,7 +7204,9 @@ func (f *fakeFilesystemStamper) StampFilesystemFormatted(_ context.Context, reso return nil } -func (f *fakeFilesystemStamper) StampFilesystemObserved(_ context.Context, resourceName string) error { +func (f *fakeFilesystemStamper) StampFilesystemObserved(_ context.Context, rdName, nodeName string) error { + resourceName := rdName + "." + nodeName + f.mu.Lock() defer f.mu.Unlock() diff --git a/pkg/satellite/reconciler_metadata_created_test.go b/pkg/satellite/reconciler_metadata_created_test.go index 99975b46..707d659a 100644 --- a/pkg/satellite/reconciler_metadata_created_test.go +++ b/pkg/satellite/reconciler_metadata_created_test.go @@ -42,7 +42,9 @@ type fakeMetadataStamper struct { stamps func(string) bool // optional: returns true to fail the call } -func (f *fakeMetadataStamper) StampMetadataCreated(_ context.Context, resourceName string) error { +func (f *fakeMetadataStamper) StampMetadataCreated(_ context.Context, rdName, nodeName string) error { + resourceName := rdName + "." + nodeName + f.mu.Lock() defer f.mu.Unlock() diff --git a/pkg/satellite/removed_peers_spelling_internal_test.go b/pkg/satellite/removed_peers_spelling_internal_test.go new file mode 100644 index 00000000..4d674f51 --- /dev/null +++ b/pkg/satellite/removed_peers_spelling_internal_test.go @@ -0,0 +1,47 @@ +// 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 satellite + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cozystack/blockstor/pkg/satellite/intent" +) + +// The .res file names a peer as it was rendered, Node-2; the intent now +// spells it node-2. The peer has not left, so it is not dropped. +func TestARenamedPeerSpellingIsNotARemovedPeer(t *testing.T) { + t.Parallel() + + res := filepath.Join(t.TempDir(), "pvc.res") + body := "resource \"pvc\" {\n on Node-1 {\n }\n on Node-2 {\n }\n on node-3 {\n }\n}\n" + + if err := os.WriteFile(res, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + dr := &intent.DesiredResource{Name: "pvc", Peers: []intent.DesiredPeer{{Name: "node-2"}}} + + got := computeRemovedPeers(res, dr, "node-1") + if len(got) != 1 || got[0] != "node-3" { + t.Errorf("removed peers %v; want only node-3", got) + } +} diff --git a/pkg/store/cascade.go b/pkg/store/cascade.go index c0bd32e2..c2496621 100644 --- a/pkg/store/cascade.go +++ b/pkg/store/cascade.go @@ -26,6 +26,7 @@ import ( "sort" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // The teardown a delete has to perform, shared by the two write doors. @@ -102,7 +103,8 @@ func CascadeDeleteResources(ctx context.Context, st Store, rdName string) error // Three spellings are asked, see NodeSpellings. The one that remains out of // reach is a spelling that is neither the caller's, nor the folded one, nor // the node's registered one: a replica created by hand under yet another case. -// That is the boundary FoldName documents. The merge base asked only the +// A field selector compares the stored spelling verbatim, which is the +// boundary pkg/store/k8s/spellings.go documents. The merge base asked only the // caller's spelling, verbatim. func ReplicasOnNode(ctx context.Context, st Store, node string) ([]apiv1.Resource, error) { spellings, err := NodeSpellings(ctx, st, node) @@ -123,7 +125,7 @@ func ReplicasOnNode(ctx context.Context, st Store, node string) ([]apiv1.Resourc // `node-1` asked in one spelling, since that one is already folded, and found // nothing. // -// It is read from the node listing and compared with FoldName, rather than +// It is read from the node listing and compared with naming.Key, rather than // taken from Nodes().Get: the Kubernetes store's Get folds through the CRD // slug, the in-memory one does not, and the listing gives both the same // answer. A node that is not registered (already gone, or never was) @@ -141,7 +143,7 @@ func NodeSpellings(ctx context.Context, st Store, node string) ([]string, error) } } - add(FoldName(node)) + add(naming.Key(node)) nodes, err := st.Nodes().List(ctx) if err != nil { @@ -149,7 +151,7 @@ func NodeSpellings(ctx context.Context, st Store, node string) ([]string, error) } for i := range nodes { - if FoldName(nodes[i].Name) == FoldName(node) { + if naming.Key(nodes[i].Name) == naming.Key(node) { add(nodes[i].Name) } } @@ -184,7 +186,7 @@ func inEverySpelling[T any]( } for i := range found { - key := FoldName(name(&found[i])) + key := naming.Key(name(&found[i])) if _, dup := seen[key]; dup { continue } @@ -263,11 +265,11 @@ func ReferencesUnderSpellings(ctx context.Context, st Store, spellings []string) rscRefs := make([]string, 0, len(resources)) for i := range resources { - if _, dup := seen[resources[i].Name]; dup { + if _, dup := seen[naming.Key(resources[i].Name)]; dup { continue } - seen[resources[i].Name] = struct{}{} + seen[naming.Key(resources[i].Name)] = struct{}{} rscRefs = append(rscRefs, resources[i].Name) } @@ -285,7 +287,7 @@ func ReferencesUnderSpellings(ctx context.Context, st Store, spellings []string) // node — and it names a pool the operator cannot remove, so the // node becomes undeletable. The REST refusal has always skipped // it; the shared implementation has to as well. - if pools[i].StoragePoolName == apiv1.DfltDisklessStorPoolName { + if naming.Same(pools[i].StoragePoolName, apiv1.DfltDisklessStorPoolName) { continue } diff --git a/pkg/store/clone_snapshot.go b/pkg/store/clone_snapshot.go index aaa3db3c..0a5474bd 100644 --- a/pkg/store/clone_snapshot.go +++ b/pkg/store/clone_snapshot.go @@ -29,6 +29,7 @@ import ( "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // CloneSnapshotOwnerProp marks a snapshot the clone path took for itself. Its @@ -83,7 +84,7 @@ func OwnedCloneSnapshot(ctx context.Context, st Store, rdName string) (ClonedSna return ClonedSnapshotRef{}, fmt.Errorf("check whether %s owns %s/%s: %w", rdName, source, snapName, err) } - if !strings.EqualFold(snap.Props[CloneSnapshotOwnerProp], rdName) { + if !naming.Same(snap.Props[CloneSnapshotOwnerProp], rdName) { return ClonedSnapshotRef{}, nil } @@ -282,7 +283,7 @@ func uncachedSnapshot(ctx context.Context, st Store, srcRD, snapName string) (ap } for i := range snaps { - if strings.EqualFold(snaps[i].Name, snapName) { + if naming.Same(snaps[i].Name, snapName) { return snaps[i], nil } } @@ -352,7 +353,7 @@ func restoredFrom(definitions []apiv1.ResourceDefinition, source, snapshot strin var names []string for i := range definitions { - if strings.EqualFold(definitions[i].Props[RestoreFromSnapshotProp], marker) { + if SameRestoreMarker(definitions[i].Props[RestoreFromSnapshotProp], marker) { names = append(names, definitions[i].Name) } } diff --git a/pkg/store/inmemory.go b/pkg/store/inmemory.go index c4dd60d0..a9c35573 100644 --- a/pkg/store/inmemory.go +++ b/pkg/store/inmemory.go @@ -27,6 +27,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // InMemory is the Phase 1 store: thread-safe, RAM-backed, lost on restart. @@ -144,7 +145,7 @@ func (s *inMemoryStoragePoolDefinitions) Get(_ context.Context, name string) (St s.mu.RLock() defer s.mu.RUnlock() - def, ok := s.m[name] + def, ok := s.m[naming.Key(name)] if !ok { return StoragePoolDefinition{}, errors.Wrapf(ErrNotFound, "storage pool definition %q", name) } @@ -163,11 +164,11 @@ func (s *inMemoryStoragePoolDefinitions) Create(_ context.Context, def *StorageP s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[def.Name]; exists { + if _, exists := s.m[naming.Key(def.Name)]; exists { return errors.Wrapf(ErrAlreadyExists, "storage pool definition %q", def.Name) } - s.m[def.Name] = copyStoragePoolDefinition(*def) + s.m[naming.Key(def.Name)] = copyStoragePoolDefinition(*def) return nil } @@ -182,11 +183,11 @@ func (s *inMemoryStoragePoolDefinitions) Update(_ context.Context, def *StorageP s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[def.Name]; !exists { + if _, exists := s.m[naming.Key(def.Name)]; !exists { return errors.Wrapf(ErrNotFound, "storage pool definition %q", def.Name) } - s.m[def.Name] = copyStoragePoolDefinition(*def) + s.m[naming.Key(def.Name)] = copyStoragePoolDefinition(*def) return nil } @@ -197,11 +198,11 @@ func (s *inMemoryStoragePoolDefinitions) Delete(_ context.Context, name string) s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[name]; !exists { + if _, exists := s.m[naming.Key(name)]; !exists { return errors.Wrapf(ErrNotFound, "storage pool definition %q", name) } - delete(s.m, name) + delete(s.m, naming.Key(name)) return nil } @@ -309,7 +310,7 @@ func (s *inMemoryNodes) Get(_ context.Context, name string) (apiv1.Node, error) s.mu.RLock() defer s.mu.RUnlock() - n, ok := s.m[name] + n, ok := s.m[naming.Key(name)] if !ok { return apiv1.Node{}, errors.Wrapf(ErrNotFound, "node %q", name) } @@ -349,11 +350,11 @@ func (s *inMemoryNodes) Create(_ context.Context, n *apiv1.Node) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[n.Name]; exists { + if _, exists := s.m[naming.Key(n.Name)]; exists { return errors.Wrapf(ErrAlreadyExists, "node %q", n.Name) } - s.m[n.Name] = *n + s.m[naming.Key(n.Name)] = *n return nil } @@ -368,11 +369,11 @@ func (s *inMemoryNodes) Update(_ context.Context, n *apiv1.Node) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[n.Name]; !exists { + if _, exists := s.m[naming.Key(n.Name)]; !exists { return errors.Wrapf(ErrNotFound, "node %q", n.Name) } - s.m[n.Name] = *n + s.m[naming.Key(n.Name)] = *n return nil } @@ -390,7 +391,7 @@ func (s *inMemoryNodes) PatchNetInterfaces(_ context.Context, name string, mutat s.mu.Lock() defer s.mu.Unlock() - node, ok := s.m[name] + node, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "node %q", name) } @@ -401,7 +402,7 @@ func (s *inMemoryNodes) PatchNetInterfaces(_ context.Context, name string, mutat } node.NetInterfaces = out - s.m[name] = node + s.m[naming.Key(name)] = node return nil } @@ -418,7 +419,7 @@ func (s *inMemoryNodes) PatchProps(_ context.Context, name string, mutate func(m s.mu.Lock() defer s.mu.Unlock() - node, ok := s.m[name] + node, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "node %q", name) } @@ -436,7 +437,7 @@ func (s *inMemoryNodes) PatchProps(_ context.Context, name string, mutate func(m return errors.Wrapf(err, "patch Props of node %q", name) } - s.m[name] = working + s.m[naming.Key(name)] = working return nil } @@ -453,7 +454,7 @@ func (s *inMemoryNodes) PatchNodeSpec(_ context.Context, name string, mutate fun s.mu.Lock() defer s.mu.Unlock() - node, ok := s.m[name] + node, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "node %q", name) } @@ -468,7 +469,7 @@ func (s *inMemoryNodes) PatchNodeSpec(_ context.Context, name string, mutate fun return errors.Wrapf(err, "patch Node %q", name) } - s.m[name] = working + s.m[naming.Key(name)] = working return nil } @@ -478,11 +479,11 @@ func (s *inMemoryNodes) Delete(_ context.Context, name string) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[name]; !exists { + if _, exists := s.m[naming.Key(name)]; !exists { return errors.Wrapf(ErrNotFound, "node %q", name) } - delete(s.m, name) + delete(s.m, naming.Key(name)) return nil } @@ -494,13 +495,13 @@ func (s *inMemoryNodes) SetConnectionStatus(_ context.Context, name, status stri s.mu.Lock() defer s.mu.Unlock() - node, ok := s.m[name] + node, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "node %q", name) } node.ConnectionStatus = status - s.m[name] = node + s.m[naming.Key(name)] = node return nil } diff --git a/pkg/store/inmemory_name_key_test.go b/pkg/store/inmemory_name_key_test.go new file mode 100644 index 00000000..80c79ea0 --- /dev/null +++ b/pkg/store/inmemory_name_key_test.go @@ -0,0 +1,107 @@ +// 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 store_test + +import ( + "errors" + "testing" + + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store" +) + +// The in-memory store keys a definition's children the way the Kubernetes +// one names them, by naming.Key: a listing of My_Res never shows the +// replicas or snapshots of my_res, a definition of its own. (The Kubernetes +// store cannot hold a replica of a definition outside RFC 1123 at all: the +// Resource CRD wants its name to be the definition's and node's names joined, +// and a hashed name never is.) +func TestInMemoryListingsKeepCaseVariantsOutsideRFC1123Apart(t *testing.T) { + t.Parallel() + + s := store.NewInMemory() + ctx := t.Context() + + for _, rd := range []string{"My_Res", "my_res"} { + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: rd}); err != nil { + t.Fatalf("Create definition %q: %v", rd, err) + } + } + + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "my_res", NodeName: "n1"}); err != nil { + t.Fatalf("Create replica: %v", err) + } + + if err := s.Snapshots().Create(ctx, &apiv1.Snapshot{Name: "snap-1", ResourceName: "my_res", Nodes: []string{"n1"}}); err != nil { + t.Fatalf("Create snapshot: %v", err) + } + + if got, err := s.Resources().ListByDefinition(ctx, "My_Res"); err != nil || len(got) != 0 { + t.Errorf("My_Res lists %d replica(s) of my_res, %v", len(got), err) + } + + if got, err := s.Snapshots().ListByDefinition(ctx, "My_Res"); err != nil || len(got) != 0 { + t.Errorf("My_Res lists %d snapshot(s) of my_res, %v", len(got), err) + } + + if got, err := s.Resources().ListByDefinition(ctx, "my_res"); err != nil || len(got) != 1 { + t.Errorf("my_res lists %d replica(s), %v; want its own one", len(got), err) + } +} + +// The in-memory store keys a snapshot, and a replica, exactly as the +// Kubernetes store names its CR: naming.Key of the definition's and the +// snapshot's (or node's) names joined as given. Outside RFC 1123 that key is +// a hash of the whole, so Snap_1 and snap_1 of one definition are two +// snapshots, and pvc-x/Snap_1 is not found as PVC-X/Snap_1. The conformance +// suite cannot pin this on both stores: the Snapshot and Resource CRDs want +// their names to be the two names joined, and a hashed name never is, so the +// Kubernetes store refuses such a child outright. +func TestInMemoryChildKeyIsTheCRName(t *testing.T) { + t.Parallel() + + s := store.NewInMemory() + ctx := t.Context() + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}); err != nil { + t.Fatalf("Create definition: %v", err) + } + + for _, snap := range []string{"Snap_1", "snap_1"} { + if err := s.Snapshots().Create(ctx, &apiv1.Snapshot{Name: snap, ResourceName: "pvc-x", Nodes: []string{"n1"}}); err != nil { + t.Errorf("Create %s: %v; want two snapshots", snap, err) + } + } + + if _, err := s.Snapshots().Get(ctx, "PVC-X", "Snap_1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("Get PVC-X/Snap_1 = %v; want ErrNotFound, as the CR name PVC-X.Snap_1 hashes apart", err) + } + + if _, err := s.Snapshots().Get(ctx, "pvc-x", "Snap_1"); err != nil { + t.Errorf("Get pvc-x/Snap_1: %v", err) + } + + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-x", NodeName: "Node_1"}); err != nil { + t.Fatalf("Create replica: %v", err) + } + + if _, err := s.Resources().Get(ctx, "PVC-X", "Node_1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("Get PVC-X/Node_1 = %v; want ErrNotFound, as the CR name PVC-X.Node_1 hashes apart", err) + } +} diff --git a/pkg/store/inmemory_resource.go b/pkg/store/inmemory_resource.go index 8d5e5f34..461c28fe 100644 --- a/pkg/store/inmemory_resource.go +++ b/pkg/store/inmemory_resource.go @@ -27,14 +27,14 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) -// rKey is the composite key (resource definition, node) for the in-memory -// resource index. -type rKey struct { - rd string - node string -} +// rKey is the in-memory resource index's key: the name the Kubernetes store +// gives the replica's CR, so the two stores tell replicas apart alike. +type rKey string + +func resourceKey(rd, node string) rKey { return rKey(naming.Key(rd + "." + node)) } type inMemoryResources struct { mu sync.RWMutex @@ -69,7 +69,7 @@ func (s *inMemoryResources) ListByNode(_ context.Context, node string) ([]apiv1. out := make([]apiv1.Resource, 0) for k := range s.m { - if s.m[k].NodeName == node { + if naming.Key(s.m[k].NodeName) == naming.Key(node) { out = append(out, s.m[k]) } } @@ -86,7 +86,7 @@ func (s *inMemoryResources) ListByDefinition(_ context.Context, rdName string) ( out := make([]apiv1.Resource, 0) for k := range s.m { - if s.m[k].Name == rdName { + if naming.Key(s.m[k].Name) == naming.Key(rdName) { out = append(out, s.m[k]) } } @@ -100,7 +100,7 @@ func (s *inMemoryResources) Get(_ context.Context, rdName, node string) (apiv1.R s.mu.RLock() defer s.mu.RUnlock() - r, ok := s.m[rKey{rdName, node}] + r, ok := s.m[resourceKey(rdName, node)] if !ok { return apiv1.Resource{}, errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -116,7 +116,7 @@ func (s *inMemoryResources) Create(_ context.Context, r *apiv1.Resource) error { s.mu.Lock() defer s.mu.Unlock() - k := rKey{r.Name, r.NodeName} + k := resourceKey(r.Name, r.NodeName) if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "resource %q on node %q", r.Name, r.NodeName) } @@ -134,7 +134,7 @@ func (s *inMemoryResources) Update(_ context.Context, r *apiv1.Resource) error { s.mu.Lock() defer s.mu.Unlock() - key := rKey{r.Name, r.NodeName} + key := resourceKey(r.Name, r.NodeName) prev, exists := s.m[key] if !exists { @@ -162,7 +162,7 @@ func (s *inMemoryResources) PatchResourceSpec(_ context.Context, rdName, node st s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := resourceKey(rdName, node) r, ok := s.m[key] if !ok { @@ -193,7 +193,7 @@ func (s *inMemoryResources) Delete(_ context.Context, rdName, node string) error s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := resourceKey(rdName, node) if _, exists := s.m[key]; !exists { return errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -216,7 +216,7 @@ func (s *inMemoryResources) DeleteIfTieBreaker(_ context.Context, rdName, node s s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := resourceKey(rdName, node) r, exists := s.m[key] if !exists { @@ -246,7 +246,7 @@ func (s *inMemoryResources) SetState(_ context.Context, rdName, node string, sta s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := resourceKey(rdName, node) existing, exists := s.m[key] if !exists { @@ -284,7 +284,7 @@ func (s *inMemoryResources) ClearDRBDPort(_ context.Context, rdName, node string s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := resourceKey(rdName, node) existing, ok := s.m[key] if !ok { @@ -313,7 +313,7 @@ func (s *inMemoryResources) VolumeStateForTest(rdName, node string, volumeNumber return apiv1.VolumeState{}, false } - st, ok := s.volStates[rKey{rdName, node}][volumeNumber] + st, ok := s.volStates[resourceKey(rdName, node)][volumeNumber] return st, ok } diff --git a/pkg/store/inmemory_resource_definition.go b/pkg/store/inmemory_resource_definition.go index 9c373c20..8853f146 100644 --- a/pkg/store/inmemory_resource_definition.go +++ b/pkg/store/inmemory_resource_definition.go @@ -26,6 +26,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) type inMemoryResourceDefinitions struct { @@ -56,7 +57,7 @@ func (s *inMemoryResourceDefinitions) Get(_ context.Context, name string) (apiv1 s.mu.RLock() defer s.mu.RUnlock() - rd, ok := s.m[name] + rd, ok := s.m[naming.Key(name)] if !ok { return apiv1.ResourceDefinition{}, errors.Wrapf(ErrNotFound, "resource definition %q", name) } @@ -77,11 +78,11 @@ func (s *inMemoryResourceDefinitions) Create(_ context.Context, rd *apiv1.Resour s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[rd.Name]; exists { + if _, exists := s.m[naming.Key(rd.Name)]; exists { return errors.Wrapf(ErrAlreadyExists, "resource definition %q", rd.Name) } - s.m[rd.Name] = *rd + s.m[naming.Key(rd.Name)] = *rd return nil } @@ -94,7 +95,7 @@ func (s *inMemoryResourceDefinitions) Update(_ context.Context, rd *apiv1.Resour s.mu.Lock() defer s.mu.Unlock() - prev, exists := s.m[rd.Name] + prev, exists := s.m[naming.Key(rd.Name)] if !exists { return errors.Wrapf(ErrNotFound, "resource definition %q", rd.Name) } @@ -108,7 +109,7 @@ func (s *inMemoryResourceDefinitions) Update(_ context.Context, rd *apiv1.Resour // Bug-021: nil wire annotations = "untouched"; empty = "clear". // Mirrors the k8s store's mergeUserAnnotationsInto contract. next.Annotations = carryAnnotationsOnNil(next.Annotations, prev.Annotations) - s.m[rd.Name] = next + s.m[naming.Key(rd.Name)] = next return nil } @@ -125,7 +126,7 @@ func (s *inMemoryResourceDefinitions) PatchResourceDefinitionSpec(_ context.Cont s.mu.Lock() defer s.mu.Unlock() - rd, ok := s.m[name] + rd, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "resource definition %q", name) } @@ -145,7 +146,7 @@ func (s *inMemoryResourceDefinitions) PatchResourceDefinitionSpec(_ context.Cont // Bug-021: same annotation contract as Update — a closure that // nils the map means "untouched", not "clear". working.Annotations = carryAnnotationsOnNil(working.Annotations, prevAnnotations) - s.m[name] = working + s.m[naming.Key(name)] = working return nil } @@ -154,11 +155,11 @@ func (s *inMemoryResourceDefinitions) Delete(_ context.Context, name string) err s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[name]; !exists { + if _, exists := s.m[naming.Key(name)]; !exists { return errors.Wrapf(ErrNotFound, "resource definition %q", name) } - delete(s.m, name) + delete(s.m, naming.Key(name)) return nil } diff --git a/pkg/store/inmemory_resource_group.go b/pkg/store/inmemory_resource_group.go index 9b61ce9f..9521b3d6 100644 --- a/pkg/store/inmemory_resource_group.go +++ b/pkg/store/inmemory_resource_group.go @@ -26,6 +26,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) type inMemoryResourceGroups struct { @@ -51,7 +52,7 @@ func (s *inMemoryResourceGroups) Get(_ context.Context, name string) (apiv1.Reso s.mu.RLock() defer s.mu.RUnlock() - rg, ok := s.m[name] + rg, ok := s.m[naming.Key(name)] if !ok { return apiv1.ResourceGroup{}, errors.Wrapf(ErrNotFound, "resource group %q", name) } @@ -67,11 +68,11 @@ func (s *inMemoryResourceGroups) Create(_ context.Context, rg *apiv1.ResourceGro s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[rg.Name]; exists { + if _, exists := s.m[naming.Key(rg.Name)]; exists { return errors.Wrapf(ErrAlreadyExists, "resource group %q", rg.Name) } - s.m[rg.Name] = *rg + s.m[naming.Key(rg.Name)] = *rg return nil } @@ -84,7 +85,7 @@ func (s *inMemoryResourceGroups) Update(_ context.Context, rg *apiv1.ResourceGro s.mu.Lock() defer s.mu.Unlock() - prev, exists := s.m[rg.Name] + prev, exists := s.m[naming.Key(rg.Name)] if !exists { return errors.Wrapf(ErrNotFound, "resource group %q", rg.Name) } @@ -93,7 +94,7 @@ func (s *inMemoryResourceGroups) Update(_ context.Context, rg *apiv1.ResourceGro // Bug-021: nil wire annotations = "untouched"; empty = "clear". // Mirrors the k8s store's mergeUserAnnotationsInto contract. next.Annotations = carryAnnotationsOnNil(next.Annotations, prev.Annotations) - s.m[rg.Name] = next + s.m[naming.Key(rg.Name)] = next return nil } @@ -110,7 +111,7 @@ func (s *inMemoryResourceGroups) PatchResourceGroup(_ context.Context, name stri s.mu.Lock() defer s.mu.Unlock() - rg, ok := s.m[name] + rg, ok := s.m[naming.Key(name)] if !ok { return errors.Wrapf(ErrNotFound, "resource group %q", name) } @@ -130,7 +131,7 @@ func (s *inMemoryResourceGroups) PatchResourceGroup(_ context.Context, name stri // Bug-021: same annotation contract as Update — a closure that // nils the map means "untouched", not "clear". working.Annotations = carryAnnotationsOnNil(working.Annotations, prevAnnotations) - s.m[name] = working + s.m[naming.Key(name)] = working return nil } @@ -139,11 +140,11 @@ func (s *inMemoryResourceGroups) Delete(_ context.Context, name string) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.m[name]; !exists { + if _, exists := s.m[naming.Key(name)]; !exists { return errors.Wrapf(ErrNotFound, "resource group %q", name) } - delete(s.m, name) + delete(s.m, naming.Key(name)) return nil } diff --git a/pkg/store/inmemory_snapshot.go b/pkg/store/inmemory_snapshot.go index cc627bc8..0f7da052 100644 --- a/pkg/store/inmemory_snapshot.go +++ b/pkg/store/inmemory_snapshot.go @@ -27,12 +27,16 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) -type snapKey struct { - rd string - snap string -} +// snapKey is the in-memory snapshot index's key: the name the Kubernetes +// store gives the snapshot's CR, so the two stores tell snapshots apart +// alike. When the joined name falls outside RFC 1123 the key hashes it whole, +// spelling of the definition included. +type snapKey string + +func snapshotKey(rd, snap string) snapKey { return snapKey(naming.Key(rd + "." + snap)) } type inMemorySnapshots struct { mu sync.RWMutex @@ -66,7 +70,7 @@ func (s *inMemorySnapshots) ListByDefinition(_ context.Context, rdName string) ( out := make([]apiv1.Snapshot, 0) for k := range s.m { - if s.m[k].ResourceName == rdName { + if naming.Key(s.m[k].ResourceName) == naming.Key(rdName) { out = append(out, s.m[k]) } } @@ -86,7 +90,7 @@ func (s *inMemorySnapshots) Get(_ context.Context, rdName, snapName string) (api s.mu.RLock() defer s.mu.RUnlock() - snap, ok := s.m[snapKey{rdName, snapName}] + snap, ok := s.m[snapshotKey(rdName, snapName)] if !ok { return apiv1.Snapshot{}, errors.Wrapf(ErrNotFound, "snapshot %q on resource definition %q", snapName, rdName) } @@ -102,7 +106,7 @@ func (s *inMemorySnapshots) Create(_ context.Context, snap *apiv1.Snapshot) erro s.mu.Lock() defer s.mu.Unlock() - k := snapKey{snap.ResourceName, snap.Name} + k := snapshotKey(snap.ResourceName, snap.Name) if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "snapshot %q on resource definition %q", snap.Name, snap.ResourceName) } @@ -120,7 +124,7 @@ func (s *inMemorySnapshots) Update(_ context.Context, snap *apiv1.Snapshot) erro s.mu.Lock() defer s.mu.Unlock() - key := snapKey{snap.ResourceName, snap.Name} + key := snapshotKey(snap.ResourceName, snap.Name) prev, exists := s.m[key] if !exists { @@ -144,7 +148,7 @@ func (s *inMemorySnapshots) PatchSnapshotProps( s.mu.Lock() defer s.mu.Unlock() - key := snapKey{rdName, snapName} + key := snapshotKey(rdName, snapName) snap, ok := s.m[key] if !ok { @@ -177,7 +181,7 @@ func (s *inMemorySnapshots) Delete(_ context.Context, rdName, snapName string) e s.mu.Lock() defer s.mu.Unlock() - k := snapKey{rdName, snapName} + k := snapshotKey(rdName, snapName) if _, exists := s.m[k]; !exists { return errors.Wrapf(ErrNotFound, "snapshot %q on resource definition %q", snapName, rdName) } diff --git a/pkg/store/inmemory_storage_pool.go b/pkg/store/inmemory_storage_pool.go index d88340aa..2ad7ff45 100644 --- a/pkg/store/inmemory_storage_pool.go +++ b/pkg/store/inmemory_storage_pool.go @@ -26,6 +26,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // spKey is the composite map key for the in-memory storage-pool index. @@ -71,7 +72,7 @@ func (s *inMemoryStoragePools) ListByNode(_ context.Context, node string) ([]api out := make([]apiv1.StoragePool, 0) for k := range s.m { - if s.m[k].NodeName == node { + if naming.Same(s.m[k].NodeName, node) { sp := s.m[k] withDerivedState(&sp) @@ -91,7 +92,7 @@ func (s *inMemoryStoragePools) Get(_ context.Context, node, pool string) (apiv1. s.mu.RLock() defer s.mu.RUnlock() - sp, ok := s.m[spKey{node, pool}] + sp, ok := s.m[spKey{naming.Key(node), naming.Key(pool)}] if !ok { return apiv1.StoragePool{}, errors.Wrapf(ErrNotFound, "storage pool %q on node %q", pool, node) } @@ -175,7 +176,7 @@ func (s *inMemoryStoragePools) Create(_ context.Context, sp *apiv1.StoragePool) s.mu.Lock() defer s.mu.Unlock() - k := spKey{sp.NodeName, sp.StoragePoolName} + k := spKey{naming.Key(sp.NodeName), naming.Key(sp.StoragePoolName)} if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "storage pool %q on node %q", sp.StoragePoolName, sp.NodeName) } @@ -194,7 +195,7 @@ func (s *inMemoryStoragePools) Update(_ context.Context, sp *apiv1.StoragePool) s.mu.Lock() defer s.mu.Unlock() - k := spKey{sp.NodeName, sp.StoragePoolName} + k := spKey{naming.Key(sp.NodeName), naming.Key(sp.StoragePoolName)} if _, exists := s.m[k]; !exists { return errors.Wrapf(ErrNotFound, "storage pool %q on node %q", sp.StoragePoolName, sp.NodeName) } @@ -210,7 +211,7 @@ func (s *inMemoryStoragePools) SetCapacity(_ context.Context, node, pool string, s.mu.Lock() defer s.mu.Unlock() - key := spKey{node, pool} + key := spKey{naming.Key(node), naming.Key(pool)} existing, ok := s.m[key] if !ok { @@ -237,7 +238,7 @@ func (s *inMemoryStoragePools) PatchStoragePoolSpec(_ context.Context, node, poo s.mu.Lock() defer s.mu.Unlock() - key := spKey{node, pool} + key := spKey{naming.Key(node), naming.Key(pool)} sp, ok := s.m[key] if !ok { @@ -264,7 +265,7 @@ func (s *inMemoryStoragePools) Delete(_ context.Context, node, pool string) erro s.mu.Lock() defer s.mu.Unlock() - k := spKey{node, pool} + k := spKey{naming.Key(node), naming.Key(pool)} if _, exists := s.m[k]; !exists { return errors.Wrapf(ErrNotFound, "storage pool %q on node %q", pool, node) } diff --git a/pkg/store/inmemory_volume_definition.go b/pkg/store/inmemory_volume_definition.go index 88627759..f5e0adc7 100644 --- a/pkg/store/inmemory_volume_definition.go +++ b/pkg/store/inmemory_volume_definition.go @@ -26,6 +26,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // vdKey is the composite key (resource definition, volume number). @@ -49,10 +50,10 @@ func (s *inMemoryVolumeDefinitions) List(_ context.Context, rdName string) ([]ap // through the RD's folded metadata.name. Compared verbatim, a mixed-case // lookup answered differently depending on which side of the CLI's // bulk-read cutoff it landed on. - want := FoldName(rdName) + want := naming.Key(rdName) for k := range s.m { - if FoldName(k.rd) == want { + if naming.Key(k.rd) == want { out = append(out, s.m[k]) } } @@ -63,8 +64,8 @@ func (s *inMemoryVolumeDefinitions) List(_ context.Context, rdName string) ([]ap } // ListAll groups every stored volume by its parent definition, keyed the way -// the Kubernetes store keys it: folded, because LINSTOR names are -// case-insensitive and the caller's spelling need not be the stored one. +// the Kubernetes store keys it, by naming.Key: the caller's spelling need not +// be the stored one. func (s *inMemoryVolumeDefinitions) ListAll(_ context.Context) (map[string][]apiv1.VolumeDefinition, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -72,7 +73,7 @@ func (s *inMemoryVolumeDefinitions) ListAll(_ context.Context) (map[string][]api out := make(map[string][]apiv1.VolumeDefinition) for k := range s.m { - key := FoldName(k.rd) + key := naming.Key(k.rd) out[key] = append(out[key], s.m[k]) } @@ -88,7 +89,7 @@ func (s *inMemoryVolumeDefinitions) Get(_ context.Context, rdName string, volume s.mu.RLock() defer s.mu.RUnlock() - vd, ok := s.m[vdKey{rdName, volumeNumber}] + vd, ok := s.m[vdKey{naming.Key(rdName), volumeNumber}] if !ok { return apiv1.VolumeDefinition{}, errors.Wrapf(ErrNotFound, "volume %d on resource definition %q", volumeNumber, rdName) } @@ -104,7 +105,7 @@ func (s *inMemoryVolumeDefinitions) Create(_ context.Context, rdName string, vd s.mu.Lock() defer s.mu.Unlock() - k := vdKey{rdName, vd.VolumeNumber} + k := vdKey{naming.Key(rdName), vd.VolumeNumber} if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "volume %d on resource definition %q", vd.VolumeNumber, rdName) } @@ -136,7 +137,7 @@ func (s *inMemoryVolumeDefinitions) CreateAutoNumbered(_ context.Context, rdName used := make(map[int32]bool) for k := range s.m { - if k.rd == rdName { + if k.rd == naming.Key(rdName) { used[k.vol] = true } } @@ -146,7 +147,7 @@ func (s *inMemoryVolumeDefinitions) CreateAutoNumbered(_ context.Context, rdName } vd.VolumeNumber = assigned - s.m[vdKey{rdName, assigned}] = *vd + s.m[vdKey{naming.Key(rdName), assigned}] = *vd return assigned, nil } @@ -159,7 +160,7 @@ func (s *inMemoryVolumeDefinitions) Update(_ context.Context, rdName string, vd s.mu.Lock() defer s.mu.Unlock() - k := vdKey{rdName, vd.VolumeNumber} + k := vdKey{naming.Key(rdName), vd.VolumeNumber} if _, exists := s.m[k]; !exists { return errors.Wrapf(ErrNotFound, "volume %d on resource definition %q", vd.VolumeNumber, rdName) } @@ -181,7 +182,7 @@ func (s *inMemoryVolumeDefinitions) PatchVolumeDefinitionSpec(_ context.Context, s.mu.Lock() defer s.mu.Unlock() - key := vdKey{rdName, volumeNumber} + key := vdKey{naming.Key(rdName), volumeNumber} vd, ok := s.m[key] if !ok { @@ -207,7 +208,7 @@ func (s *inMemoryVolumeDefinitions) Delete(_ context.Context, rdName string, vol s.mu.Lock() defer s.mu.Unlock() - k := vdKey{rdName, volumeNumber} + k := vdKey{naming.Key(rdName), volumeNumber} if _, exists := s.m[k]; !exists { return errors.Wrapf(ErrNotFound, "volume %d on resource definition %q", volumeNumber, rdName) } diff --git a/pkg/store/k8s/crdname.go b/pkg/store/k8s/crdname.go index a36516c9..c35b56ee 100644 --- a/pkg/store/k8s/crdname.go +++ b/pkg/store/k8s/crdname.go @@ -19,12 +19,9 @@ limitations under the License. package k8s import ( - "crypto/sha256" - "encoding/hex" - "regexp" - "strings" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/cozystack/blockstor/pkg/naming" ) // AnnotationLinstorName preserves the original LINSTOR-side name when @@ -72,72 +69,9 @@ func OriginalName(meta *metav1.ObjectMeta) string { return meta.Name } -// Name turns a LINSTOR-shaped name (which Java accepts in mixed case -// and with characters k8s rejects) into something the API server will -// store without complaining. LINSTOR identifiers are case-insensitive -// (`DfltRscGrp` and `dfltrscgrp` address the same object), so we -// lowercase first — if the lowercased form is rfc1123-valid, return -// it unchanged. Otherwise we replace non-allowed runs with '-' and -// append a short SHA-256 prefix so distinct LINSTOR names never -// collide on the same k8s name. -// -// linstor-csi names PVCs like `vol-` (already lowercase) but -// upstream LINSTOR's `DfltRscGrp` and csi-sanity's `DeleteSnapshot- -// volume-1-…` need normalisation. Lowercasing in this function is -// the single normalization point: every store Get/Update/Delete -// routes through Name(), so the same input lands on the same CRD -// regardless of case. The canonical CamelCase spelling (when the -// caller supplied one) round-trips via the `blockstor.io/linstor-name` -// annotation — see SetOriginalName / OriginalName. +// Name is the CR name the store keeps an object under: naming.Key of its +// LINSTOR name. The registered spelling round-trips through the +// AnnotationLinstorName annotation; see SetOriginalName and OriginalName. func Name(in string) string { - lower := strings.ToLower(in) - - if lower != "" && len(lower) <= maxK8sName && rfc1123.MatchString(lower) { - return lower - } - - var builder strings.Builder - - prevDash := true - - for _, r := range lower { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - builder.WriteRune(r) - - prevDash = false - default: - if !prevDash { - builder.WriteByte('-') - - prevDash = true - } - } - } - - slug := strings.Trim(builder.String(), "-") - - digest := sha256.Sum256([]byte(in)) - prefix := hex.EncodeToString(digest[:hashPrefixBytes]) - out := prefix + "-" + slug - - if len(out) > maxK8sName { - out = out[:maxK8sName] - } - - return out + return naming.Key(in) } - -// rfc1123 covers k8s metadata.Name validation; declared once at -// package init so the regex compile cost amortises. -var rfc1123 = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) - -const ( - // maxK8sName is the metadata.Name length cap (DNS-1123). - maxK8sName = 253 - - // hashPrefixBytes drives K8sName's collision-resistant prefix - // length. 4 bytes → 8 hex chars → 2^32 distinct slugs, plenty - // for storage objects. - hashPrefixBytes = 4 -) diff --git a/pkg/store/k8s/field_index_test.go b/pkg/store/k8s/field_index_test.go index 981dc2a7..eec31848 100644 --- a/pkg/store/k8s/field_index_test.go +++ b/pkg/store/k8s/field_index_test.go @@ -19,7 +19,7 @@ import ( crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" - "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -218,7 +218,7 @@ func TestScopedReadsOnAnUncachedClient(t *testing.T) { t.Errorf("ListByDefinition = %v, %v; want both replicas", byDefinition, err) } - _ = store.FoldName("") + _ = naming.Key("") } // A label is written by whoever created the object, and piraeus and operators diff --git a/pkg/store/k8s/folded_spelling_children_test.go b/pkg/store/k8s/folded_spelling_children_test.go new file mode 100644 index 00000000..2be61589 --- /dev/null +++ b/pkg/store/k8s/folded_spelling_children_test.go @@ -0,0 +1,155 @@ +// 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_test + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// A child written straight to the API server under the folded name, the +// way the autosnapshot controller names a snapshot after the definition's +// CR, is found under the definition's registered mixed-case spelling too. +func TestChildrenUnderTheFoldedSpellingOfAMixedCaseDefinition(t *testing.T) { + if fixture == nil { + t.Skip("envtest assets not installed; run `make setup-envtest` to enable") + } + + t.Cleanup(func() { wipeAll(t, fixture.client) }) + + ctx := t.Context() + st := k8s.New(fixture.client) + + if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "Pvc-Fold"}); err != nil { + t.Fatalf("create the definition: %v", err) + } + + if err := fixture.client.Create(ctx, &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-fold.auto-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "pvc-fold", SnapshotName: "auto-1"}, + }); err != nil { + t.Fatalf("raw create the snapshot: %v", err) + } + + if err := fixture.client.Create(ctx, &crdv1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-fold.node-a"}, + Spec: crdv1alpha1.ResourceSpec{ResourceDefinitionName: "pvc-fold", NodeName: "node-a"}, + }); err != nil { + t.Fatalf("raw create the replica: %v", err) + } + + snaps, err := st.Snapshots().ListByDefinitionUncached(ctx, "Pvc-Fold") + if err != nil || len(snaps) != 1 { + t.Errorf("snapshots of Pvc-Fold = %v, %v; want the one written under pvc-fold", snaps, err) + } + + replicas, err := st.Resources().ListByDefinition(ctx, "Pvc-Fold") + if err != nil || len(replicas) != 1 { + t.Errorf("replicas of Pvc-Fold = %v, %v; want the one written under pvc-fold", replicas, err) + } +} + +// Where the field selector is not served the listing falls back to a full +// scan, and the scan keeps the same spellings: a mixed-case definition's +// snapshot written under the folded name is found, another definition's is +// not. +func TestSnapshotScanFallbackKeepsEverySpelling(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + rd := crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Scan")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Scan") + + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &rd, + &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-scan.auto-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "pvc-scan", SnapshotName: "auto-1"}, + }, + &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-other.auto-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "pvc-other", SnapshotName: "auto-1"}, + }, + ).Build() + + snaps, err := k8s.New(cli).Snapshots().ListByDefinition(context.Background(), "Pvc-Scan") + if err != nil || len(snaps) != 1 || snaps[0].Name != "auto-1" { + t.Errorf("snapshots of Pvc-Scan = %v, %v; want only the one written under pvc-scan", snaps, err) + } +} + +// A row written before the store kept CR names in spec carries the +// registered spelling, Pvc-Reg on Node-Reg. Asked in a third spelling, the +// listings find it through the registered one. +func TestARowStoredUnderTheRegisteredSpellingIsFound(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + rd := crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Reg")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Reg") + + node := crdv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Node-Reg")}} + k8s.SetOriginalName(&node.ObjectMeta, "Node-Reg") + + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &rd, &node, + &crdv1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-reg.node-reg"}, + Spec: crdv1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Reg", NodeName: "Node-Reg"}, + }, + &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-reg.snap-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "Pvc-Reg", SnapshotName: "snap-1"}, + }, + ).Build() + + ctx := context.Background() + st := k8s.New(cli) + + replicas, err := st.Resources().ListByDefinition(ctx, "PVC-REG") + if err != nil || len(replicas) != 1 { + t.Errorf("replicas of PVC-REG = %v, %v; want the one stored under Pvc-Reg", replicas, err) + } + + onNode, err := st.Resources().ListByNode(ctx, "NODE-REG") + if err != nil || len(onNode) != 1 { + t.Errorf("replicas on NODE-REG = %v, %v; want the one stored on Node-Reg", onNode, err) + } + + snaps, err := st.Snapshots().ListByDefinition(ctx, "PVC-REG") + if err != nil || len(snaps) != 1 { + t.Errorf("snapshots of PVC-REG = %v, %v; want the one stored under Pvc-Reg", snaps, err) + } +} diff --git a/pkg/store/k8s/physical_device_node_spelling_test.go b/pkg/store/k8s/physical_device_node_spelling_test.go new file mode 100644 index 00000000..4d64b69b --- /dev/null +++ b/pkg/store/k8s/physical_device_node_spelling_test.go @@ -0,0 +1,57 @@ +// 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_test + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// The satellite labels its devices with its node's CR name, node-1; `ps cdp` +// names the node as registered, Node-1, and still lists them. +func TestDevicesAreListedUnderAnotherSpellingOfTheirNode(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + dev := &crdv1alpha1.PhysicalDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1-sdb", + Labels: map[string]string{crdv1alpha1.PhysicalDeviceLabelNode: "node-1"}, + }, + } + + st := k8s.New(fake.NewClientBuilder().WithScheme(scheme).WithObjects(dev).Build()) + + for _, asked := range []string{"node-1", "Node-1", "NODE-1"} { + got, err := st.PhysicalDevices().ListForNode(t.Context(), asked) + if err != nil || len(got) != 1 { + t.Errorf("ListForNode(%s) = %d devices, %v; want the device labelled node-1", asked, len(got), err) + } + } +} diff --git a/pkg/store/k8s/physicaldevices.go b/pkg/store/k8s/physicaldevices.go index 62fd068c..c3da8cc7 100644 --- a/pkg/store/k8s/physicaldevices.go +++ b/pkg/store/k8s/physicaldevices.go @@ -26,6 +26,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/util/retry" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -72,18 +73,31 @@ func (s *physicalDevices) List(ctx context.Context) ([]apiv1.PhysicalDevice, err return out, nil } +// ListForNode lists the devices the satellite of nodeName labelled as its +// own. The satellite labels them with its node's CR name, node-1, while an +// operator may name the node as registered, Node-1, so the label is asked +// under the CR name, and under the given spelling too when that is another +// valid label value. func (s *physicalDevices) ListForNode(ctx context.Context, nodeName string) ([]apiv1.PhysicalDevice, error) { - var crdList crdv1alpha1.PhysicalDeviceList - - err := s.c.List(ctx, &crdList, - ctrlclient.MatchingLabels{crdv1alpha1.PhysicalDeviceLabelNode: nodeName}) - if err != nil { - return nil, errors.Wrapf(err, "list PhysicalDevice CRDs for node %q", nodeName) + values := []string{Name(nodeName)} + if nodeName != values[0] && len(validation.IsValidLabelValue(nodeName)) == 0 { + values = append(values, nodeName) } - out := make([]apiv1.PhysicalDevice, 0, len(crdList.Items)) - for i := range crdList.Items { - out = append(out, crdToWirePhysicalDevice(&crdList.Items[i])) + out := []apiv1.PhysicalDevice{} + + for _, value := range values { + var crdList crdv1alpha1.PhysicalDeviceList + + err := s.c.List(ctx, &crdList, + ctrlclient.MatchingLabels{crdv1alpha1.PhysicalDeviceLabelNode: value}) + if err != nil { + return nil, errors.Wrapf(err, "list PhysicalDevice CRDs for node %q", nodeName) + } + + for i := range crdList.Items { + out = append(out, crdToWirePhysicalDevice(&crdList.Items[i])) + } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) diff --git a/pkg/store/k8s/pool_node_spelling_test.go b/pkg/store/k8s/pool_node_spelling_test.go new file mode 100644 index 00000000..ce22730b --- /dev/null +++ b/pkg/store/k8s/pool_node_spelling_test.go @@ -0,0 +1,64 @@ +// 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_test + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// A pool keeps the node spelling it was created with, node-1, and a +// replica's pool lookup may ask under Node-1, or the other way round. The +// store finds the pool under either. +func TestStoragePoolIsFoundUnderAnotherSpellingOfItsNode(t *testing.T) { + t.Parallel() + + for created, asked := range map[string]string{"node-1": "Node-1", "Node-1": "node-1"} { + t.Run(created, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + pool := &crdv1alpha1.StoragePool{ + ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("data." + created)}, + Spec: crdv1alpha1.StoragePoolSpec{NodeName: created, PoolName: "data", ProviderKind: "LVM"}, + } + + st := k8s.New(fake.NewClientBuilder().WithScheme(scheme).WithObjects(pool).Build()) + + got, err := st.StoragePools().Get(t.Context(), asked, "data") + if err != nil { + t.Fatalf("Get(%s, data) for a pool created on %s: %v", asked, created, err) + } + + if got.StoragePoolName != "data" { + t.Errorf("Get(%s, data) = %+v", asked, got) + } + }) + } +} diff --git a/pkg/store/k8s/resources.go b/pkg/store/k8s/resources.go index f1e933fc..479f3eaf 100644 --- a/pkg/store/k8s/resources.go +++ b/pkg/store/k8s/resources.go @@ -87,8 +87,15 @@ func (s *resources) List(ctx context.Context) ([]apiv1.Resource, error) { // partial-but-correct subset — the Bug 038 shape, where the missing replicas // were invisible rather than an error. func (s *resources) ListByNode(ctx context.Context, node string) ([]apiv1.Resource, error) { - out, err := s.listScoped(ctx, s.nodeScopedReader(), FieldResourceNodeName, node, - func(r *crdv1alpha1.Resource) bool { return r.Spec.NodeName == node }) + reader := s.nodeScopedReader() + + spellings, err := nodeSpellings(ctx, reader, node) + if err != nil { + return nil, err + } + + out, err := s.listUnderSpellings(ctx, reader, FieldResourceNodeName, spellings, + func(r *crdv1alpha1.Resource, spelling string) bool { return r.Spec.NodeName == spelling }) if err != nil { return nil, err } @@ -176,9 +183,35 @@ func (s *resources) Create(ctx context.Context, in *apiv1.Resource) error { return errors.New("nil Resource") } - crd := wireToCRDResource(in) + // The spec stores the definition's and the node's CR names, which is + // what the controller and the satellite look them up and compare by, + // and the registered spellings are kept for display (see spellings.go). + // The object itself is named from the registered spellings, the way + // Get addresses it. + rd, err := storedRDName(ctx, s.c, in.Name) + if err != nil { + return err + } - err := s.c.Create(ctx, crd) + node, err := storedNodeName(ctx, s.c, in.NodeName) + if err != nil { + return err + } + + shown := *in + shown.Name = rd.display + shown.NodeName = node.display + + crd := wireToCRDResource(&shown) + dropNameAnnotations(crd.Annotations) + crd.Spec.ResourceDefinitionName = rd.crName + crd.Spec.NodeName = node.crName + crd.Labels[LabelResourceDefinition] = rd.crName + crd.Labels[LabelNodeName] = node.crName + rd.annotate(&crd.ObjectMeta, AnnotationLinstorRDName) + node.annotate(&crd.ObjectMeta, AnnotationLinstorNodeName) + + err = s.c.Create(ctx, crd) if err != nil { if apierrors.IsAlreadyExists(err) { return errors.Wrapf(store.ErrAlreadyExists, "resource %q on node %q", in.Name, in.NodeName) @@ -231,11 +264,15 @@ func (s *resources) Update(ctx context.Context, in *apiv1.Resource) error { // a golinstor REST modify can't strip it and flip the satellite back // to the unsafe day0-skip default (the offline-safety regression). prevSkipInit := existing.Spec.SkipInitialSync + // The names in spec are the parents' CR names, fixed at Create; the + // wire carries the display spellings (see spellings.go). + prevRD, prevNode := existing.Spec.ResourceDefinitionName, existing.Spec.NodeName existing.Spec = wireToCRDResourceSpec(in) existing.Spec.Volumes = prevVolumes existing.Spec.DRBDPort = prevPort existing.Spec.DRBDNodeID = prevNodeID existing.Spec.SkipInitialSync = prevSkipInit + existing.Spec.ResourceDefinitionName, existing.Spec.NodeName = prevRD, prevNode // Bug 67: round-trip wire-side Annotations through metadata so the // REST writer's `blockstor.io/peer-changed` bump actually reaches @@ -249,8 +286,8 @@ func (s *resources) Update(ctx context.Context, in *apiv1.Resource) error { // wipes the annotation; a concurrent RD cascade-delete that hits // `lookupVolumeNumbers` during the harm window leaks the backing // LV / zvol. Mirrors the RD / RG / Snapshot stores. - mergeUserAnnotationsInto(&existing.ObjectMeta, in.Annotations, - crdv1alpha1.ResourceAnnotationVolumeNumbers) + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(in.Annotations), + crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) err = s.c.Update(ctx, &existing) if err != nil { @@ -313,11 +350,14 @@ func (s *resources) PatchResourceSpec(ctx context.Context, rdName, node string, // Skip-init-sync hardening: carry SkipInitialSync across — see // the matching carry-across in `Update` above. prevSkipInit := existing.Spec.SkipInitialSync + // The CR names in spec: see the matching carry-across in `Update`. + prevRD, prevNode := existing.Spec.ResourceDefinitionName, existing.Spec.NodeName existing.Spec = wireToCRDResourceSpec(&wire) existing.Spec.Volumes = prevVolumes existing.Spec.DRBDPort = prevPort existing.Spec.DRBDNodeID = prevNodeID existing.Spec.SkipInitialSync = prevSkipInit + existing.Spec.ResourceDefinitionName, existing.Spec.NodeName = prevRD, prevNode // Bug 210: merge rather than wholesale-replace so the // satellite-stamped `blockstor.io/volume-numbers` (Bug 107) @@ -328,8 +368,8 @@ func (s *resources) PatchResourceSpec(ctx context.Context, rdName, node string, // annotation invariant uniform with the wholesale `Update` // path and protects against future closures that explicitly // reset live.Annotations. - mergeUserAnnotationsInto(&existing.ObjectMeta, wire.Annotations, - crdv1alpha1.ResourceAnnotationVolumeNumbers) + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(wire.Annotations), + crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) return s.c.Patch(ctx, &existing, ctrlclient.MergeFromWithOptions(base, ctrlclient.MergeFromWithOptimisticLock{})) }), "patch Resource %s/%s", rdName, node) @@ -545,8 +585,13 @@ func (s *resources) ClearDRBDPort(ctx context.Context, rdName, node string) erro func (s *resources) listByDefinitionThrough( ctx context.Context, reader ctrlclient.Reader, rdName string, ) ([]apiv1.Resource, error) { - out, err := s.listScoped(ctx, reader, FieldResourceDefinitionName, rdName, - func(r *crdv1alpha1.Resource) bool { return r.Spec.ResourceDefinitionName == rdName }) + spellings, err := rdSpellings(ctx, reader, rdName) + if err != nil { + return nil, err + } + + out, err := s.listUnderSpellings(ctx, reader, FieldResourceDefinitionName, spellings, + func(r *crdv1alpha1.Resource, spelling string) bool { return r.Spec.ResourceDefinitionName == spelling }) if err != nil { return nil, err } @@ -632,8 +677,8 @@ func crdToWireResource(crd *crdv1alpha1.Resource) apiv1.Resource { } return apiv1.Resource{ - Name: crd.Spec.ResourceDefinitionName, - NodeName: crd.Spec.NodeName, + Name: shownName(&crd.ObjectMeta, AnnotationLinstorRDName, crd.Spec.ResourceDefinitionName), + NodeName: shownName(&crd.ObjectMeta, AnnotationLinstorNodeName, crd.Spec.NodeName), Props: props, Flags: withDeletingFlag(crd.Spec.Flags, crd.DeletionTimestamp != nil), // Resource CRD has no LayerStack — that lives on the parent RD. @@ -654,7 +699,7 @@ func crdToWireResource(crd *crdv1alpha1.Resource) apiv1.Resource { crd.Status.Connections, ), UUID: string(crd.UID), - Annotations: cloneAnnotations(crd.Annotations), + Annotations: withoutNameAnnotations(cloneAnnotations(crd.Annotations)), // Upstream LINSTOR populates `create_timestamp` (the Python // CLI's `CreatedOn` column). The Resource CRD's // metadata.creationTimestamp is the per-replica creation time @@ -1095,6 +1140,43 @@ func (s *resources) listScoped( return s.listExhaustively(ctx, reader, field, value, keep) } +// listUnderSpellings runs listScoped once per spelling and returns every +// replica once. The CRD name folds the definition and the node, so two rows +// with the same folded pair are one object. +func (s *resources) listUnderSpellings( + ctx context.Context, reader ctrlclient.Reader, field string, spellings []string, + keep func(*crdv1alpha1.Resource, string) bool, +) ([]apiv1.Resource, error) { + var out []apiv1.Resource + + seen := map[string]struct{}{} + + for _, spelling := range spellings { + found, err := s.listScoped(ctx, reader, field, spelling, + func(r *crdv1alpha1.Resource) bool { return keep(r, spelling) }) + if err != nil { + return nil, err + } + + for i := range found { + key := resourceCRDName(found[i].Name, found[i].NodeName) + if _, dup := seen[key]; dup { + continue + } + + seen[key] = struct{}{} + + out = append(out, found[i]) + } + } + + if out == nil { + out = []apiv1.Resource{} + } + + return out, nil +} + // listExhaustively is the pre-selectable-field read: every Resource, filtered // here. It is what listScoped falls back to, and it stays correct whatever the // server can or cannot select on. diff --git a/pkg/store/k8s/snapshots.go b/pkg/store/k8s/snapshots.go index 60bf39a8..8311da15 100644 --- a/pkg/store/k8s/snapshots.go +++ b/pkg/store/k8s/snapshots.go @@ -21,6 +21,7 @@ package k8s import ( "context" "maps" + "slices" "sort" "github.com/cockroachdb/errors" @@ -100,21 +101,7 @@ func (s *snapshots) List(ctx context.Context) ([]apiv1.Snapshot, error) { // and the mop-up missing them too. Same label blindness the Resource and // StoragePool reads were moved off, one kind over and on a delete gate. func (s *snapshots) ListByDefinition(ctx context.Context, rdName string) ([]apiv1.Snapshot, error) { - var crdList crdv1alpha1.SnapshotList - - err := s.c.List(ctx, &crdList, ctrlclient.MatchingFields{FieldSnapshotDefinitionName: rdName}) - if err != nil { - if !SelectorUnsupported(err) { - return nil, errors.Wrapf(err, "list Snapshot CRDs for RD %q", rdName) - } - - log.FromContext(ctx).V(1).Info("scoped Snapshot read unavailable; reading every snapshot instead", - "resourceDefinition", rdName, "reason", err.Error()) - - return s.listByDefinitionExhaustively(ctx, s.c, rdName) - } - - return s.wireSnapshots(ctx, rdName, crdList.Items) + return s.listByDefinitionThrough(ctx, s.c, rdName) } // ListByDefinitionUncached reads the definition's snapshots from the API server @@ -136,21 +123,7 @@ func (s *snapshots) ListByDefinitionUncached(ctx context.Context, rdName string) return s.ListByDefinition(ctx, rdName) } - var crdList crdv1alpha1.SnapshotList - - err := s.apiReader.List(ctx, &crdList, ctrlclient.MatchingFields{FieldSnapshotDefinitionName: rdName}) - if err != nil { - if !SelectorUnsupported(err) { - return nil, errors.Wrapf(err, "list Snapshot CRDs for RD %q", rdName) - } - - log.FromContext(ctx).V(1).Info("scoped uncached Snapshot read unavailable; reading every snapshot instead", - "resourceDefinition", rdName, "reason", err.Error()) - - return s.listByDefinitionExhaustively(ctx, s.apiReader, rdName) - } - - return s.wireSnapshots(ctx, rdName, crdList.Items) + return s.listByDefinitionThrough(ctx, s.apiReader, rdName) } func (s *snapshots) Get(ctx context.Context, rdName, snapName string) (apiv1.Snapshot, error) { @@ -175,9 +148,23 @@ func (s *snapshots) Create(ctx context.Context, in *apiv1.Snapshot) error { return errors.New("nil Snapshot") } - crd := wireToCRDSnapshot(in) + // The spec stores the definition's CR name and the registered spelling + // is kept for display, as for a replica; see spellings.go. + rd, err := storedRDName(ctx, s.c, in.ResourceName) + if err != nil { + return err + } - err := s.c.Create(ctx, crd) + shown := *in + shown.ResourceName = rd.display + + crd := wireToCRDSnapshot(&shown) + dropNameAnnotations(crd.Annotations) + crd.Spec.ResourceDefinitionName = rd.crName + crd.Labels[LabelResourceDefinition] = rd.crName + rd.annotate(&crd.ObjectMeta, AnnotationLinstorRDName) + + err = s.c.Create(ctx, crd) if err != nil { if apierrors.IsAlreadyExists(err) { return errors.Wrapf(store.ErrAlreadyExists, "snapshot %q on RD %q", in.Name, in.ResourceName) @@ -238,13 +225,17 @@ func (s *snapshots) Update(ctx context.Context, in *apiv1.Snapshot) error { // clobber it — a lost GroupSize would let the barrier open early // (or never) and break the cross-RD suspend-window invariant. preservedGroupSize := existing.Spec.GroupSize + // The definition's CR name, fixed at Create; the wire carries the + // display spelling (see spellings.go). + preservedRD := existing.Spec.ResourceDefinitionName existing.Spec = wireToCRDSnapshotSpec(in) existing.Spec.SuspendIO = preservedSuspendIO existing.Spec.TakeSnapshot = preservedTakeSnapshot existing.Spec.GroupID = preservedGroupID existing.Spec.GroupSize = preservedGroupSize - mergeUserAnnotationsInto(&existing.ObjectMeta, in.Annotations) + existing.Spec.ResourceDefinitionName = preservedRD + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(in.Annotations), AnnotationLinstorRDName) return s.c.Update(ctx, &existing) }), "update Snapshot %s/%s", in.ResourceName, in.Name) @@ -315,6 +306,50 @@ func (s *snapshots) Delete(ctx context.Context, rdName, snapName string) error { return nil } +// listByDefinitionThrough lists the definition's snapshots under every +// spelling of its name (see spellings.go), through one reader, and falls back +// to the exhaustive read on that same reader when the server cannot select. +func (s *snapshots) listByDefinitionThrough( + ctx context.Context, reader ctrlclient.Reader, rdName string, +) ([]apiv1.Snapshot, error) { + spellings, err := rdSpellings(ctx, reader, rdName) + if err != nil { + return nil, err + } + + var kept []crdv1alpha1.Snapshot + + seen := map[string]struct{}{} + + for _, spelling := range spellings { + var crdList crdv1alpha1.SnapshotList + + err := reader.List(ctx, &crdList, ctrlclient.MatchingFields{FieldSnapshotDefinitionName: spelling}) + if err != nil { + if !SelectorUnsupported(err) { + return nil, errors.Wrapf(err, "list Snapshot CRDs for RD %q", spelling) + } + + log.FromContext(ctx).V(1).Info("scoped Snapshot read unavailable; reading every snapshot instead", + "resourceDefinition", rdName, "reason", err.Error()) + + return s.listByDefinitionExhaustively(ctx, reader, rdName, spellings) + } + + for i := range crdList.Items { + if _, dup := seen[crdList.Items[i].Name]; dup { + continue + } + + seen[crdList.Items[i].Name] = struct{}{} + + kept = append(kept, crdList.Items[i]) + } + } + + return s.wireSnapshots(ctx, rdName, kept) +} + // getParentRD fetches the parent ResourceDefinition for a Snapshot, // returning nil + the not-found error when the parent has already // been deleted (orphan Snapshot — the view layer still has to render @@ -376,10 +411,10 @@ func crdToWireSnapshot( ) apiv1.Snapshot { out := apiv1.Snapshot{ Name: crd.Spec.SnapshotName, - ResourceName: crd.Spec.ResourceDefinitionName, + ResourceName: shownName(&crd.ObjectMeta, AnnotationLinstorRDName, crd.Spec.ResourceDefinitionName), Nodes: crd.Spec.Nodes, Props: crd.Spec.Props, - Annotations: userAnnotations(crd.Annotations), + Annotations: withoutNameAnnotations(userAnnotations(crd.Annotations)), UUID: string(crd.UID), SnapshotDefinitionProps: crd.Spec.Props, } @@ -589,7 +624,7 @@ func wireToCRDSnapshotSpec(in *apiv1.Snapshot) crdv1alpha1.SnapshotSpec { // authoritative Spec.ResourceDefinitionName, read through the reader the // scoped attempt used so a fallback never changes which one answers. func (s *snapshots) listByDefinitionExhaustively( - ctx context.Context, reader ctrlclient.Reader, rdName string, + ctx context.Context, reader ctrlclient.Reader, rdName string, spellings []string, ) ([]apiv1.Snapshot, error) { var crdList crdv1alpha1.SnapshotList @@ -601,7 +636,7 @@ func (s *snapshots) listByDefinitionExhaustively( kept := make([]crdv1alpha1.Snapshot, 0, len(crdList.Items)) for i := range crdList.Items { - if crdList.Items[i].Spec.ResourceDefinitionName == rdName { + if slices.Contains(spellings, crdList.Items[i].Spec.ResourceDefinitionName) { kept = append(kept, crdList.Items[i]) } } diff --git a/pkg/store/k8s/spelling_lookup_test.go b/pkg/store/k8s/spelling_lookup_test.go new file mode 100644 index 00000000..23c90896 --- /dev/null +++ b/pkg/store/k8s/spelling_lookup_test.go @@ -0,0 +1,250 @@ +// 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_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +var errParentRead = errors.New("injected parent read failure") + +func spellingScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + return scheme +} + +func registeredRD(name string) *crdv1alpha1.ResourceDefinition { + rd := &crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(name)}} + k8s.SetOriginalName(&rd.ObjectMeta, name) + + return rd +} + +func registeredNode(name string) *crdv1alpha1.Node { + node := &crdv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(name)}} + k8s.SetOriginalName(&node.ObjectMeta, name) + + return node +} + +// failParentGets fails the first Get of a definition or node, the way an +// API server times out once; every later Get answers. +func failParentGets(base ctrlclient.WithWatch) ctrlclient.WithWatch { + var failed atomic.Bool + + return interceptor.NewClient(base, interceptor.Funcs{ + Get: func(ctx context.Context, c ctrlclient.WithWatch, key ctrlclient.ObjectKey, obj ctrlclient.Object, opts ...ctrlclient.GetOption) error { + switch obj.(type) { + case *crdv1alpha1.ResourceDefinition, *crdv1alpha1.Node: + if failed.CompareAndSwap(false, true) { + return errParentRead + } + } + + return c.Get(ctx, key, obj, opts...) + }, + }) +} + +// A listing whose parent cannot be read fails, rather than coming back short +// of the rows stored under the registered spelling: a delete would read the +// short answer as "no snapshots" and leave them behind. +func TestAListingWhoseParentCannotBeReadFails(t *testing.T) { + t.Parallel() + + newStore := func() *k8s.Store { + return k8s.New(failParentGets(fake.NewClientBuilder().WithScheme(spellingScheme(t)).WithObjects( + registeredRD("Pvc-Reg"), registeredNode("Node-Reg"), + &crdv1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-reg.node-reg"}, + Spec: crdv1alpha1.ResourceSpec{ResourceDefinitionName: "Pvc-Reg", NodeName: "Node-Reg"}, + }, + &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-reg.snap-1"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "Pvc-Reg", SnapshotName: "snap-1"}, + }, + ).Build())) + } + + ctx := context.Background() + + if snaps, err := newStore().Snapshots().ListByDefinitionUncached(ctx, "PVC-REG"); !errors.Is(err, errParentRead) { + t.Errorf("uncached snapshots of PVC-REG = %v, %v; want the read failure", snaps, err) + } + + if snaps, err := newStore().Snapshots().ListByDefinition(ctx, "PVC-REG"); !errors.Is(err, errParentRead) { + t.Errorf("snapshots of PVC-REG = %v, %v; want the read failure", snaps, err) + } + + if replicas, err := newStore().Resources().ListByDefinition(ctx, "PVC-REG"); !errors.Is(err, errParentRead) { + t.Errorf("replicas of PVC-REG = %v, %v; want the read failure", replicas, err) + } + + if replicas, err := newStore().Resources().ListByNode(ctx, "NODE-REG"); !errors.Is(err, errParentRead) { + t.Errorf("replicas on NODE-REG = %v, %v; want the read failure", replicas, err) + } +} + +// A write whose parent cannot be read fails, rather than storing a name the +// readers will not find. +func TestAWriteWhoseParentCannotBeReadFails(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + newStore := func() *k8s.Store { + return k8s.New(failParentGets(fake.NewClientBuilder().WithScheme(spellingScheme(t)).WithObjects( + registeredRD("Pvc-W"), registeredNode("Node-W"), + ).Build())) + } + + if err := newStore().Resources().Create(ctx, &apiv1.Resource{Name: "pvc-w", NodeName: "node-w"}); !errors.Is(err, errParentRead) { + t.Errorf("replica create = %v; want the read failure", err) + } + + if err := newStore().Snapshots().Create(ctx, &apiv1.Snapshot{Name: "snap-1", ResourceName: "pvc-w"}); !errors.Is(err, errParentRead) { + t.Errorf("snapshot create = %v; want the read failure", err) + } +} + +// My_Res and my_res are outside RFC 1123, so they hash to two CR names and +// are two definitions. A listing of one does not return the other's rows, +// including rows the other stores under its own lowercase spelling. +func TestAListingAsksNoOtherDefinitionsSpelling(t *testing.T) { + t.Parallel() + + st := k8s.New(fake.NewClientBuilder().WithScheme(spellingScheme(t)).WithObjects( + registeredRD("My_Res"), registeredRD("my_res"), registeredNode("node-a"), + &crdv1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: "lower-replica"}, + Spec: crdv1alpha1.ResourceSpec{ResourceDefinitionName: "my_res", NodeName: "node-a"}, + }, + &crdv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "lower-snap"}, + Spec: crdv1alpha1.SnapshotSpec{ResourceDefinitionName: "my_res", SnapshotName: "snap-1"}, + }, + ).Build()) + + ctx := context.Background() + + if replicas, err := st.Resources().ListByDefinition(ctx, "My_Res"); err != nil || len(replicas) != 0 { + t.Errorf("replicas of My_Res = %v, %v; want none, the row is my_res's", replicas, err) + } + + if snaps, err := st.Snapshots().ListByDefinition(ctx, "My_Res"); err != nil || len(snaps) != 0 { + t.Errorf("snapshots of My_Res = %v, %v; want none, the row is my_res's", snaps, err) + } + + if replicas, err := st.Resources().ListByDefinition(ctx, "my_res"); err != nil || len(replicas) != 1 { + t.Errorf("replicas of my_res = %v, %v; want its own row", replicas, err) + } + + if snaps, err := st.Snapshots().ListByDefinition(ctx, "my_res"); err != nil || len(snaps) != 1 { + t.Errorf("snapshots of my_res = %v, %v; want its own row", snaps, err) + } +} + +// The names a child shows for its parents are the store's to derive. A +// caller writing the reserved annotations, on create or on update, does not +// replace them. +func TestACallerCannotForgeTheShownParentNames(t *testing.T) { + t.Parallel() + + forged := map[string]string{ + k8s.AnnotationLinstorRDName: "wrong-rd", + k8s.AnnotationLinstorNodeName: "wrong-node", + "user/kept": "yes", + } + + st := k8s.New(fake.NewClientBuilder().WithScheme(spellingScheme(t)).WithObjects( + registeredRD("lower-rd"), registeredNode("lower-node"), + ).Build()) + + ctx := context.Background() + + if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "lower-rd", NodeName: "lower-node", Annotations: forged}); err != nil { + t.Fatalf("replica create: %v", err) + } + + got, err := st.Resources().Get(ctx, "lower-rd", "lower-node") + if err != nil || got.Name != "lower-rd" || got.NodeName != "lower-node" || got.Annotations["user/kept"] != "yes" { + t.Errorf("replica after create = %+v, %v; want lower-rd/lower-node with the caller's own annotation", got, err) + } + + got.Annotations = forged + if err := st.Resources().Update(ctx, &got); err != nil { + t.Fatalf("replica update: %v", err) + } + + got, err = st.Resources().Get(ctx, "lower-rd", "lower-node") + if err != nil || got.Name != "lower-rd" || got.NodeName != "lower-node" { + t.Errorf("replica after update = %+v, %v; want lower-rd/lower-node", got, err) + } + + if err := st.Resources().PatchResourceSpec(ctx, "lower-rd", "lower-node", func(r *apiv1.Resource) error { + r.Annotations = forged + + return nil + }); err != nil { + t.Fatalf("replica patch: %v", err) + } + + got, err = st.Resources().Get(ctx, "lower-rd", "lower-node") + if err != nil || got.Name != "lower-rd" || got.NodeName != "lower-node" { + t.Errorf("replica after patch = %+v, %v; want lower-rd/lower-node", got, err) + } + + if err := st.Snapshots().Create(ctx, &apiv1.Snapshot{Name: "snap-1", ResourceName: "lower-rd", Annotations: forged}); err != nil { + t.Fatalf("snapshot create: %v", err) + } + + snap, err := st.Snapshots().Get(ctx, "lower-rd", "snap-1") + if err != nil || snap.ResourceName != "lower-rd" { + t.Errorf("snapshot after create = %+v, %v; want it under lower-rd", snap, err) + } + + snap.Annotations = forged + if err := st.Snapshots().Update(ctx, &snap); err != nil { + t.Fatalf("snapshot update: %v", err) + } + + snap, err = st.Snapshots().Get(ctx, "lower-rd", "snap-1") + if err != nil || snap.ResourceName != "lower-rd" { + t.Errorf("snapshot after update = %+v, %v; want it under lower-rd", snap, err) + } +} diff --git a/pkg/store/k8s/spellings.go b/pkg/store/k8s/spellings.go new file mode 100644 index 00000000..d4a0de0a --- /dev/null +++ b/pkg/store/k8s/spellings.go @@ -0,0 +1,251 @@ +// 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 ( + "context" + "maps" + "slices" + + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" +) + +// LINSTOR names are case-insensitive, and the store addresses one object +// through the folded CRD slug, so `PVC-X` and `pvc-x` are the same +// definition. A replica or snapshot keeps its definition's name in spec, +// though, and a field selector compares that value exactly. A listing asked +// in one spelling then missed rows stored in another, and on a delete that is +// the destructive kind of short answer: the snapshot gate passed over +// snapshots it could not see, and the cascade left replicas with live DRBD +// under a definition that was gone. +// +// Writes therefore store the CR name in spec: the definition's (and for a +// replica the node's) metadata.name. Every reader that Gets the parent by the +// spec value, or compares it with a CR name, meets the object it means, and +// the satellite derives the DRBD resource and volume names from that same +// lowercase value. The spelling the definition or node was registered under +// is kept beside it in an annotation, for what the REST surface shows. +// +// Rows stored, or adopted from LINSTOR, before this may still carry another +// spelling in spec, so a scoped listing asks under every spelling the name +// can reasonably have been stored in: the one given, its CR name, and the +// registered one. The lowercase spelling is not one of them on its own: for a +// name outside RFC 1123 it is a different definition, hashed to another CR +// name, and for any other name the CR name already is the lowercase one. +// +// A parent that cannot be read is an error, never "not there": a listing that +// went on without its registered spelling would come back short, and on a +// delete the snapshot gate would pass over what it could not see. + +const ( + // AnnotationLinstorRDName keeps the spelling a replica's or snapshot's + // definition was registered under, when it differs from the CR name the + // spec stores. Read back for display only. + AnnotationLinstorRDName = "blockstor.io/linstor-resource-definition-name" + + // AnnotationLinstorNodeName does the same for a replica's node. + AnnotationLinstorNodeName = "blockstor.io/linstor-node-name" +) + +// ResourceCRName is the name the store gives the Resource CR of the replica +// of rd on node, both as registered. A satellite that knows a resource by +// its definition's CR name maps back through the registered spellings +// before calling it; the CR name alone does not hash to the same value. +func ResourceCRName(rd, node string) string { + return resourceCRDName(rd, node) +} + +// SnapshotCRName is the name the store gives the Snapshot CR snap of rd, +// as registered; see ResourceCRName. +func SnapshotCRName(rd, snap string) string { + return snapshotCRDName(rd, snap) +} + +// storedName is what a child stores for one of its parents: the parent's CR +// name, for spec, and the spelling to show. +type storedName struct { + crName string + display string +} + +// annotate records the display spelling on meta under key when it differs +// from the CR name the spec carries. +func (n storedName) annotate(meta *metav1.ObjectMeta, key string) { + if n.display == n.crName { + return + } + + if meta.Annotations == nil { + meta.Annotations = map[string]string{} + } + + meta.Annotations[key] = n.display +} + +// storedRDName answers what a replica or snapshot of rdName stores. A +// definition that is not there (a caller that skipped the check) is stored +// under the CR name it would have and shown as given. +func storedRDName(ctx context.Context, reader ctrlclient.Reader, rdName string) (storedName, error) { + found, ok, err := lookupRD(ctx, reader, rdName) + if err != nil || ok { + return found, err + } + + return storedName{crName: Name(rdName), display: rdName}, nil +} + +// storedNodeName answers what a replica on node stores, the same way. +// `r c pvc NODE-MIX` over a node registered as `Node-Mix` stores `node-mix` +// and shows `Node-Mix`. +func storedNodeName(ctx context.Context, reader ctrlclient.Reader, node string) (storedName, error) { + found, ok, err := lookupNode(ctx, reader, node) + if err != nil || ok { + return found, err + } + + return storedName{crName: Name(node), display: node}, nil +} + +// shownName is the spelling to show for a parent a child stores as stored: +// the annotation when the write kept one, the spec value otherwise. +func shownName(meta *metav1.ObjectMeta, key, stored string) string { + if v, ok := meta.Annotations[key]; ok && v != "" { + return v + } + + return stored +} + +func lookupRD(ctx context.Context, reader ctrlclient.Reader, rdName string) (storedName, bool, error) { + var crd crdv1alpha1.ResourceDefinition + + err := reader.Get(ctx, types.NamespacedName{Name: Name(rdName)}, &crd) + if err != nil { + if apierrors.IsNotFound(err) { + return storedName{}, false, nil + } + + return storedName{}, false, errors.Wrapf(err, "get ResourceDefinition %q", rdName) + } + + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true, nil +} + +func lookupNode(ctx context.Context, reader ctrlclient.Reader, node string) (storedName, bool, error) { + var crd crdv1alpha1.Node + + err := reader.Get(ctx, types.NamespacedName{Name: Name(node)}, &crd) + if err != nil { + if apierrors.IsNotFound(err) { + return storedName{}, false, nil + } + + return storedName{}, false, errors.Wrapf(err, "get Node %q", node) + } + + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true, nil +} + +// rdSpellings and nodeSpellings answer the spellings to ask under. +func rdSpellings(ctx context.Context, reader ctrlclient.Reader, rdName string) ([]string, error) { + found, ok, err := lookupRD(ctx, reader, rdName) + if err != nil { + return nil, err + } + + return spellingSet(rdName, found, ok), nil +} + +func nodeSpellings(ctx context.Context, reader ctrlclient.Reader, node string) ([]string, error) { + found, ok, err := lookupNode(ctx, reader, node) + if err != nil { + return nil, err + } + + return spellingSet(node, found, ok), nil +} + +func spellingSet(given string, found storedName, haveFound bool) []string { + out := []string{given} + + add := func(spelling string) { + if !slices.Contains(out, spelling) { + out = append(out, spelling) + } + } + + add(Name(given)) + + if haveFound { + add(found.crName) + add(found.display) + } + + return out +} + +// withoutCallerNameAnnotations drops the display-spelling annotations from +// what a caller writes: the store derives them from the parents it resolves, +// and one a caller supplied would replace the names a child shows. A nil map +// stays nil, so "no annotation work" keeps its meaning. +func withoutCallerNameAnnotations(in map[string]string) map[string]string { + if in == nil { + return nil + } + + out := maps.Clone(in) + dropNameAnnotations(out) + + return out +} + +// dropNameAnnotations deletes the display-spelling annotations from m in +// place, before a write derives them from the resolved parents. +func dropNameAnnotations(m map[string]string) { + delete(m, AnnotationLinstorRDName) + delete(m, AnnotationLinstorNodeName) +} + +// withoutNameAnnotations drops the display-spelling annotations from a wire +// annotation map: they are store-internal, and the wire carries the spelling +// itself in its name fields. +func withoutNameAnnotations(in map[string]string) map[string]string { + _, rd := in[AnnotationLinstorRDName] + _, node := in[AnnotationLinstorNodeName] + + if !rd && !node { + return in + } + + out := maps.Clone(in) + delete(out, AnnotationLinstorRDName) + delete(out, AnnotationLinstorNodeName) + + if len(out) == 0 { + return nil + } + + return out +} diff --git a/pkg/store/k8s/storage_pools.go b/pkg/store/k8s/storage_pools.go index 1a02de16..9d3f5445 100644 --- a/pkg/store/k8s/storage_pools.go +++ b/pkg/store/k8s/storage_pools.go @@ -32,6 +32,7 @@ import ( crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -75,6 +76,14 @@ func crdName(node, pool string) string { return Name(pool + "." + node) } +// poolIs reports whether crd is pool on node. A pool keeps the node +// spelling it was created with, and a caller may ask in another one (a +// replica's spec carries the node's CR name), so the node is compared the +// way the store addresses it: Node-1 and node-1 are one node. +func poolIs(crd *crdv1alpha1.StoragePool, node, pool string) bool { + return crd.Spec.PoolName == pool && naming.Same(crd.Spec.NodeName, node) +} + // List returns every StoragePool CRD as a wire-shape value, sorted by // (node, pool). func (s *storagePools) List(ctx context.Context) ([]apiv1.StoragePool, error) { @@ -458,7 +467,7 @@ func (s *storagePools) resolveCRDName(ctx context.Context, node, pool string) (s // CRD that happens to collide with our canonical shape), // fall through to the List+filter pass. Mismatch is rare // enough that the cost is acceptable. - if fast.Spec.NodeName == node && fast.Spec.PoolName == pool { + if poolIs(&fast, node, pool) { return canonical, nil } } else if !apierrors.IsNotFound(err) { @@ -479,7 +488,7 @@ func (s *storagePools) resolveCRDName(ctx context.Context, node, pool string) (s for i := range crdList.Items { item := &crdList.Items[i] - if item.Spec.NodeName == node && item.Spec.PoolName == pool { + if poolIs(item, node, pool) { matches = append(matches, item.Name) } } diff --git a/pkg/store/k8s/stored_cr_names_test.go b/pkg/store/k8s/stored_cr_names_test.go new file mode 100644 index 00000000..573179a7 --- /dev/null +++ b/pkg/store/k8s/stored_cr_names_test.go @@ -0,0 +1,191 @@ +// 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_test + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// mixedCaseParents is a fake API server holding a definition registered as +// Pvc-Mix and a node registered as Node-1, both under their folded CR names. +func mixedCaseParents(t *testing.T) ctrlclient.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := crdv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + rd := crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Mix")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") + + node := crdv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Node-1")}} + k8s.SetOriginalName(&node.ObjectMeta, "Node-1") + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(&rd, &node).Build() +} + +// A replica of a mixed-case definition stores the definition's and the +// node's CR names in spec, whatever spelling it was created in: the +// controller and the satellite Get the definition and the node by those +// values and compare them with CR names, and the satellite names the DRBD +// resource and its volumes after the definition's. The wire still shows +// the registered spellings, and a later wire write keeps both. +func TestReplicaStoresTheCRNamesOfItsParents(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cli := mixedCaseParents(t) + s := k8s.New(cli).Resources() + + if err := s.Create(ctx, &apiv1.Resource{Name: "PVC-MIX", NodeName: "NODE-1"}); err != nil { + t.Fatalf("Create: %v", err) + } + + assertSpec := func(stage string) { + t.Helper() + + var list crdv1alpha1.ResourceList + if err := cli.List(ctx, &list); err != nil || len(list.Items) != 1 { + t.Fatalf("%s: list replicas = %v, %v; want one", stage, list.Items, err) + } + + spec := list.Items[0].Spec + if spec.ResourceDefinitionName != "pvc-mix" || spec.NodeName != "node-1" { + t.Errorf("%s: spec names %q/%q, want the CR names pvc-mix/node-1", + stage, spec.ResourceDefinitionName, spec.NodeName) + } + } + + assertSpec("create") + + got, err := s.Get(ctx, "pvc-mix", "node-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Name != "Pvc-Mix" || got.NodeName != "Node-1" { + t.Errorf("wire names %q/%q, want the registered Pvc-Mix/Node-1", got.Name, got.NodeName) + } + + if _, leaked := got.Annotations[k8s.AnnotationLinstorRDName]; leaked { + t.Errorf("wire annotations %v carry the store-internal display spelling", got.Annotations) + } + + // A wire that carries any annotation replaces the user keys: the + // display spellings are store-internal and must survive it. + got.Props = map[string]string{"Aux/k": "v"} + got.Annotations = map[string]string{"example.com/user": "u"} + + if err := s.Update(ctx, &got); err != nil { + t.Fatalf("Update: %v", err) + } + + assertSpec("update") + assertShown := func(stage string) { + t.Helper() + + shown, err := s.Get(ctx, "pvc-mix", "node-1") + if err != nil || shown.Name != "Pvc-Mix" || shown.NodeName != "Node-1" { + t.Errorf("%s: shown as %q/%q, %v; want Pvc-Mix/Node-1", stage, shown.Name, shown.NodeName, err) + } + } + + assertShown("update") + + if err := s.PatchResourceSpec(ctx, "Pvc-Mix", "Node-1", func(r *apiv1.Resource) error { + r.Props["Aux/k"] = "w" + r.Annotations = map[string]string{"example.com/user": "p"} + + return nil + }); err != nil { + t.Fatalf("PatchResourceSpec: %v", err) + } + + assertSpec("patch") + assertShown("patch") + + again, err := s.Get(ctx, "Pvc-Mix", "Node-1") + if err != nil || again.Name != "Pvc-Mix" || again.NodeName != "Node-1" { + t.Errorf("after the writes: %q/%q, %v; want Pvc-Mix/Node-1", again.Name, again.NodeName, err) + } +} + +// A snapshot of a mixed-case definition stores the definition's CR name the +// same way and shows the registered spelling. +func TestSnapshotStoresTheCRNameOfItsDefinition(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cli := mixedCaseParents(t) + s := k8s.New(cli).Snapshots() + + if err := s.Create(ctx, &apiv1.Snapshot{Name: "snap-1", ResourceName: "PVC-MIX", Nodes: []string{"node-1"}}); err != nil { + t.Fatalf("Create: %v", err) + } + + stored := func(stage string) { + t.Helper() + + var crd crdv1alpha1.Snapshot + if err := cli.Get(ctx, types.NamespacedName{Name: "pvc-mix.snap-1"}, &crd); err != nil { + t.Fatalf("%s: get the snapshot CR: %v", stage, err) + } + + if crd.Spec.ResourceDefinitionName != "pvc-mix" { + t.Errorf("%s: spec definition %q, want the CR name pvc-mix", stage, crd.Spec.ResourceDefinitionName) + } + } + + stored("create") + + got, err := s.Get(ctx, "pvc-mix", "snap-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.ResourceName != "Pvc-Mix" { + t.Errorf("wire definition %q, want the registered Pvc-Mix", got.ResourceName) + } + + got.Props = map[string]string{"Aux/k": "v"} + got.Annotations = map[string]string{"example.com/user": "u"} + + if err := s.Update(ctx, &got); err != nil { + t.Fatalf("Update: %v", err) + } + + stored("update") + + shown, err := s.Get(ctx, "pvc-mix", "snap-1") + if err != nil || shown.ResourceName != "Pvc-Mix" { + t.Errorf("after an update carrying an annotation: shown as %q, %v; want Pvc-Mix", shown.ResourceName, err) + } +} diff --git a/pkg/store/k8s/volume_definitions.go b/pkg/store/k8s/volume_definitions.go index 1e17679d..498e3b30 100644 --- a/pkg/store/k8s/volume_definitions.go +++ b/pkg/store/k8s/volume_definitions.go @@ -31,6 +31,7 @@ import ( crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -92,7 +93,7 @@ func wireVDsOf(rd *crdv1alpha1.ResourceDefinition) []apiv1.VolumeDefinition { // ResourceDefinition CRDs, so the request count does not grow with the number // of definitions. // -// Keyed by store.FoldName of the definition's LINSTOR name: the caller holds +// Keyed by naming.Key of the definition's LINSTOR name: the caller holds // whatever spelling its own objects carry, which for a replica is // Spec.ResourceDefinitionName and need not match the definition's own. func (s *volumeDefinitions) ListAll(ctx context.Context) (map[string][]apiv1.VolumeDefinition, error) { @@ -115,7 +116,7 @@ func (s *volumeDefinitions) ListAll(ctx context.Context) (map[string][]apiv1.Vol sort.Slice(vds, func(a, b int) bool { return vds[a].VolumeNumber < vds[b].VolumeNumber }) - out[store.FoldName(OriginalName(&rd.ObjectMeta))] = vds + out[naming.Key(OriginalName(&rd.ObjectMeta))] = vds } return out, nil diff --git a/pkg/store/leftover.go b/pkg/store/leftover.go index 2e398f09..15a846db 100644 --- a/pkg/store/leftover.go +++ b/pkg/store/leftover.go @@ -23,9 +23,9 @@ import ( "errors" "fmt" "slices" - "strings" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // LeftoverProgress is where a target carrying a clone's or a restore's marker @@ -235,7 +235,7 @@ func MissingReplicas(ctx context.Context, st Store, rdName string, wanted []stri for _, node := range wanted { placed := slices.ContainsFunc(replicas, func(r apiv1.Resource) bool { - return strings.EqualFold(r.NodeName, node) && !slices.Contains(r.Flags, apiv1.ResourceFlagDelete) && + return naming.Same(r.NodeName, node) && !slices.Contains(r.Flags, apiv1.ResourceFlagDelete) && HoldsData(&r) }) if !placed { diff --git a/pkg/store/local_props.go b/pkg/store/local_props.go index 7a9b83d2..0cf1ff96 100644 --- a/pkg/store/local_props.go +++ b/pkg/store/local_props.go @@ -27,6 +27,7 @@ import ( "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/passphrase" ) @@ -36,6 +37,26 @@ import ( // read it to recognise their own leftover. const RestoreFromSnapshotProp = "BlockstorRestoreFromSnapshot" +// SameRestoreMarker reports whether two RestoreFromSnapshotProp values name +// the same snapshot of the same definition. Each side may spell the names +// differently, one written from the stored objects and one from a caller's +// request, so the parts are compared as the store addresses the objects +// (naming.Same), never by folding the whole string. +func SameRestoreMarker(a, b string) bool { + partsA, partsB := strings.Split(a, ":"), strings.Split(b, ":") + if len(partsA) != len(partsB) { + return false + } + + for i := range partsA { + if !naming.Same(partsA[i], partsB[i]) { + return false + } + } + + return true +} + // RollbackAbandonedProp marks a resource definition whose compensation started // and did not report finishing, with the step it stopped at. The replay gate of // the clone path refuses over it. diff --git a/pkg/store/restore_marker_test.go b/pkg/store/restore_marker_test.go new file mode 100644 index 00000000..fa94dd58 --- /dev/null +++ b/pkg/store/restore_marker_test.go @@ -0,0 +1,49 @@ +// 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 store_test + +import ( + "testing" + + "github.com/cozystack/blockstor/pkg/store" +) + +// A restore marker names a definition and a snapshot. Each half compares as +// the store addresses the object, not as one folded string: PVC-X and pvc-x +// are one definition, My_Res and my_res are two. +func TestSameRestoreMarker(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + a, b string + want bool + }{ + {"pvc-x:snap-1", "pvc-x:snap-1", true}, + {"PVC-X:Snap-1", "pvc-x:snap-1", true}, + {"My_Res:snap-1", "my_res:snap-1", false}, + {"My_Res:snap-1", "My_Res:snap-1", true}, + {"pvc-x:snap-1", "pvc-x:snap-2", false}, + {"pvc-x:snap-1", "pvc-x", false}, + {"", "pvc-x:snap-1", false}, + } { + if got := store.SameRestoreMarker(tc.a, tc.b); got != tc.want { + t.Errorf("SameRestoreMarker(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} diff --git a/pkg/store/restore_source.go b/pkg/store/restore_source.go index af3a947a..165e6f63 100644 --- a/pkg/store/restore_source.go +++ b/pkg/store/restore_source.go @@ -138,7 +138,7 @@ func releaseRestoreSource(ctx context.Context, st Store, srcRD, snapName string) } for i := range rds { - if !strings.EqualFold(rds[i].Props[RestoreFromSnapshotProp], marker) { + if !SameRestoreMarker(rds[i].Props[RestoreFromSnapshotProp], marker) { continue } @@ -236,7 +236,7 @@ func releaseRestoreMarker(ctx context.Context, st Store, rdName, marker string) err := st.ResourceDefinitions().PatchResourceDefinitionSpec(ctx, rdName, func(rd *apiv1.ResourceDefinition) error { // Re-checked on the object the patch writes: a marker rewritten in // between is somebody else's restore. - if strings.EqualFold(rd.Props[RestoreFromSnapshotProp], marker) { + if SameRestoreMarker(rd.Props[RestoreFromSnapshotProp], marker) { delete(rd.Props, RestoreFromSnapshotProp) } diff --git a/pkg/store/restore_target.go b/pkg/store/restore_target.go index 0e42c1d3..8ad4fea5 100644 --- a/pkg/store/restore_target.go +++ b/pkg/store/restore_target.go @@ -29,6 +29,7 @@ import ( "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // ErrRestoreTargetTaken reports a definition that carries a restore marker @@ -335,7 +336,7 @@ func stampRestoreMarker(ctx context.Context, st Store, rdName string, snap *apiv err = st.ResourceDefinitions().PatchResourceDefinitionSpec(ctx, rdName, func(rd *apiv1.ResourceDefinition) error { current := rd.Props[RestoreFromSnapshotProp] - if current != "" && !strings.EqualFold(current, marker) { + if current != "" && !SameRestoreMarker(current, marker) { return fmt.Errorf("%w: %q carries %q", ErrRestoreTargetTaken, rdName, current) } @@ -343,7 +344,7 @@ func stampRestoreMarker(ctx context.Context, st Store, rdName string, snap *apiv // run's, against the state the patch actually applied to. wrote = current == "" - if len(rd.LayerStack) == 0 && !strings.EqualFold(rd.ResourceGroupName, target.ResourceGroupName) { + if len(rd.LayerStack) == 0 && !naming.Same(rd.ResourceGroupName, target.ResourceGroupName) { return fmt.Errorf("%w: %q moved to group %q", ErrLayerStackUndecided, rdName, rd.ResourceGroupName) } @@ -446,7 +447,7 @@ func JudgeRestoreLeftoverOf( } marker := srcRD + ":" + snapName - if !strings.EqualFold(rd.Props[RestoreFromSnapshotProp], marker) { + if !SameRestoreMarker(rd.Props[RestoreFromSnapshotProp], marker) { return LeftoverUnfinished, false, nil } diff --git a/pkg/store/store.go b/pkg/store/store.go index 846637c6..9f99a2f5 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -26,7 +26,6 @@ package store import ( "context" - "strings" "github.com/cockroachdb/errors" @@ -299,43 +298,6 @@ type ResourceStore interface { PatchResourceSpec(ctx context.Context, rdName, node string, mutate func(*apiv1.Resource) error) error } -// FoldName canonicalises a LINSTOR object name for use as a map key, or for -// comparing two spellings of the same object. LINSTOR identifiers are -// case-insensitive — `DfltRscGrp` and `dfltrscgrp` address one resource group, -// which is why the Kubernetes store lowercases them on the way to a CRD name -// (pkg/store/k8s/crdname.go). Anything that keys objects by name owes its -// callers the same equality the store itself uses. -// -// That equality is available in process and not on the wire. A CRD's -// selectableFields declare a path, not a transform, so the API server compares -// spec.nodeName and spec.resourceDefinitionName verbatim, and the scoped reads -// built on them compare the same way — including their in-process fallback, -// deliberately, because a fallback that folded would answer a different -// question than the selector it stands in for. A replica whose spec spells its -// definition in a case the definition is not stored under is therefore missed -// by both, and `rd d`'s refusal and sweep read it that way too. -// -// The same boundary reaches `node delete`. Its refusal and its `--force` -// cascade are both node-scoped reads on `spec.nodeName`, while Nodes().Get and -// Delete fold: spell the node in a case its replicas were not written with and -// the reads see nothing, the refusal passes, and the node goes with replicas -// still pointing at it. ReplicasOnNode narrows that to one shape by asking in -// the caller's spelling, the folded one and the node's registered one; a -// replica written under any other case is still missed. `resource list` does -// not narrow its read at all for this reason, and filters the whole listing -// with the case-insensitive comparison instead. -// -// Folding on the write side instead would fold what clients read back: -// crdToWireResource reports these spec values as the object's names, and -// crdname.go's annotation exists precisely to keep the stored spelling -// (DfltRscGrp) rather than the lowercased slug, because linstor-csi and -// runbooks compare those strings. Closing the gap properly means a folded -// field beside the display one, selected on and never rendered — a schema -// change with a migration for adopted objects, not a comparison. -func FoldName(name string) string { - return strings.ToLower(name) -} - // VolumeDefinitionStore persists VolumeDefinition objects. The composite // key is (resource_definition_name, volume_number); upstream LINSTOR keeps // VolumeDefinitions inline on the ResourceDefinition, and so do we (the CRD @@ -345,8 +307,8 @@ type VolumeDefinitionStore interface { List(ctx context.Context, rdName string) ([]apiv1.VolumeDefinition, error) // ListAll returns every definition's volumes in ONE request, keyed by - // FoldName of the resource-definition name — look an entry up with - // FoldName(name), not with the name as you hold it. + // naming.Key of the resource-definition name — look an entry up with + // naming.Key(name), not with the name as you hold it. // // The fold is not decoration. A replica names its definition in // whatever case it was written with, and the definition itself is diff --git a/pkg/store/storetest/storetest.go b/pkg/store/storetest/storetest.go index ecfc1a1a..9d99562f 100644 --- a/pkg/store/storetest/storetest.go +++ b/pkg/store/storetest/storetest.go @@ -29,6 +29,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" "github.com/cozystack/blockstor/pkg/store" ) @@ -41,6 +42,7 @@ func RunNodeStore(t *testing.T, newStore Factory) { t.Helper() t.Run("ListEmpty", func(t *testing.T) { testNodeListEmpty(t, newStore) }) t.Run("CreateThenGet", func(t *testing.T) { testNodeCreateThenGet(t, newStore) }) + t.Run("AnySpellingAddressesOneNode", func(t *testing.T) { testAnySpellingAddressesOneNode(t, newStore) }) t.Run("CreateDuplicate", func(t *testing.T) { testNodeCreateDuplicate(t, newStore) }) t.Run("GetMissing", func(t *testing.T) { testNodeGetMissing(t, newStore) }) t.Run("UpdateMissing", func(t *testing.T) { testNodeUpdateMissing(t, newStore) }) @@ -377,7 +379,7 @@ func runVolumeDefinitionListAllCase(t *testing.T, newStore Factory) { } // The spelling a replica of that definition carries. - vds, ok := all[store.FoldName("pvc-mixed")] + vds, ok := all[naming.Key("pvc-mixed")] if !ok { keys := make([]string, 0, len(all)) for k := range all { @@ -508,6 +510,7 @@ func RunSnapshotStore(t *testing.T, newStore Factory) { // Bug-021: nil = untouched / non-nil = replace / empty = clear. // See annotation_contract.go. t.Run("UpdateAnnotationContract", func(t *testing.T) { testSnapshotUpdateAnnotationContract(t, newStore) }) + t.Run("AnySpellingAddressesOneSnapshot", func(t *testing.T) { testAnySpellingAddressesOneSnapshot(t, newStore) }) t.Run("CreateThenGet", func(t *testing.T) { s := newStore(t).Snapshots() ctx := t.Context() @@ -684,6 +687,7 @@ func testSnapshotUpdateNilArg(t *testing.T, newStore Factory) { // RunResourceStore exercises every branch of store.ResourceStore. func RunResourceStore(t *testing.T, newStore Factory) { t.Helper() + t.Run("AnySpellingAddressesOneReplica", func(t *testing.T) { testAnySpellingAddressesOneReplica(t, newStore) }) t.Run("ListEmpty", func(t *testing.T) { got, err := newStore(t).Resources().List(t.Context()) if err != nil { @@ -734,6 +738,17 @@ func RunResourceStore(t *testing.T, newStore Factory) { t.Run("ReferencesOnNodeUnderAnotherSpelling", func(t *testing.T) { testReferencesOnNodeUnderAnotherSpelling(t, newStore) }) + // A definition's replicas and snapshots are found whatever spelling the + // definition is asked in. `rd d PVC-X` over `pvc-x` passed the snapshot + // gate on zero rows and cascaded nothing, then the folded Delete took the + // definition and left its replicas and snapshots behind. + t.Run("ChildrenOfDefinitionUnderAnySpelling", func(t *testing.T) { + testChildrenOfDefinitionUnderAnySpelling(t, newStore) + }) + // The node-scoped listing folds the node name the same way. + t.Run("ListByNodeUnderAnySpelling", func(t *testing.T) { + testListByNodeUnderAnySpelling(t, newStore) + }) // The other direction: the operator types the canonical lowercase name // and the node, with its replicas and pools, was registered in upper // case, which is how adoption from LINSTOR writes it. Asking in the @@ -1181,6 +1196,13 @@ func testResourceDeleteIfTieBreaker(t *testing.T, newStore Factory) { // store.ResourceDefinitionStore. func RunResourceDefinitionStore(t *testing.T, newStore Factory) { t.Helper() + + t.Run("CaseVariantsOutsideRFC1123AreTwoDefinitions", func(t *testing.T) { + testCaseVariantsOutsideRFC1123AreTwoDefinitions(t, newStore) + }) + t.Run("AnySpellingAddressesOneDefinition", func(t *testing.T) { + testAnySpellingAddressesOneDefinition(t, newStore) + }) t.Run("ListEmpty", func(t *testing.T) { got, err := newStore(t).ResourceDefinitions().List(t.Context()) if err != nil { @@ -2261,3 +2283,210 @@ func testReferencesOnNodeUnderAnotherSpelling(t *testing.T, newStore Factory) { "the refusal would pass and the node would go with a replica on it", len(replicas)) } } + +// testAnySpellingAddressesOneNode: a node is addressed by the key of its name. +func testAnySpellingAddressesOneNode(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.Nodes().Create(ctx, &apiv1.Node{Name: "node-1", Type: apiv1.NodeTypeSatellite}); err != nil { + t.Fatalf("Create node-1: %v", err) + } + + if _, err := s.Nodes().Get(ctx, "Node-1"); err != nil { + t.Errorf("Get Node-1 after Create node-1: %v", err) + } + + if err := s.Nodes().Create(ctx, &apiv1.Node{Name: "Node-1", Type: apiv1.NodeTypeSatellite}); !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("Create Node-1 over node-1 = %v, want ErrAlreadyExists", err) + } +} + +// testAnySpellingAddressesOneReplica: a replica is addressed by the keys of +// its definition's and its node's names. +func testAnySpellingAddressesOneReplica(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}); err != nil { + t.Fatalf("Create definition: %v", err) + } + + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-x", NodeName: "node-1"}); err != nil { + t.Fatalf("Create replica: %v", err) + } + + if _, err := s.Resources().Get(ctx, "Pvc-X", "Node-1"); err != nil { + t.Errorf("Get Pvc-X on Node-1 after Create pvc-x on node-1: %v", err) + } + + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "PVC-X", NodeName: "NODE-1"}); !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("Create PVC-X on NODE-1 over it = %v, want ErrAlreadyExists", err) + } +} + +// testAnySpellingAddressesOneSnapshot: a snapshot is addressed by the key of +// its definition's and its own names joined, which is its CR name in the +// Kubernetes store. Inside RFC 1123 every spelling of either is one snapshot. +func testAnySpellingAddressesOneSnapshot(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}); err != nil { + t.Fatalf("Create definition: %v", err) + } + + if err := s.Snapshots().Create(ctx, &apiv1.Snapshot{Name: "snap-1", ResourceName: "pvc-x", Nodes: []string{"n1"}}); err != nil { + t.Fatalf("Create snapshot: %v", err) + } + + if _, err := s.Snapshots().Get(ctx, "PVC-X", "Snap-1"); err != nil { + t.Errorf("Get PVC-X/Snap-1 after Create pvc-x/snap-1: %v", err) + } + + err := s.Snapshots().Create(ctx, &apiv1.Snapshot{Name: "SNAP-1", ResourceName: "Pvc-X", Nodes: []string{"n1"}}) + if !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("Create Pvc-X/SNAP-1 over it = %v, want ErrAlreadyExists", err) + } + + if err := s.Snapshots().Delete(ctx, "pvc-X", "snap-1"); err != nil { + t.Fatalf("Delete pvc-X/snap-1: %v", err) + } + + if _, err := s.Snapshots().Get(ctx, "pvc-x", "snap-1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("Get pvc-x/snap-1 after the delete = %v, want ErrNotFound", err) + } +} + +// testAnySpellingAddressesOneDefinition: a definition is addressed by the key +// of its name (naming.Key), in every store. Created as pvc-x it is found as +// Pvc-X, updated and deleted as PVC-X, and a create of Pvc-X while it exists +// conflicts. +func testAnySpellingAddressesOneDefinition(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-x"}); err != nil { + t.Fatalf("Create pvc-x: %v", err) + } + + if _, err := s.ResourceDefinitions().Get(ctx, "Pvc-X"); err != nil { + t.Errorf("Get Pvc-X after Create pvc-x: %v", err) + } + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "Pvc-X"}); !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("Create Pvc-X over pvc-x = %v, want ErrAlreadyExists", err) + } + + if err := s.ResourceDefinitions().Delete(ctx, "PVC-X"); err != nil { + t.Fatalf("Delete PVC-X: %v", err) + } + + if _, err := s.ResourceDefinitions().Get(ctx, "pvc-x"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("Get pvc-x after Delete PVC-X = %v, want ErrNotFound", err) + } +} + +// testCaseVariantsOutsideRFC1123AreTwoDefinitions: a name outside RFC 1123 is +// keyed by a hash of the name as given (naming.Key), so My_Res and my_res are +// two definitions in every store. +func testCaseVariantsOutsideRFC1123AreTwoDefinitions(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + for _, rd := range []string{"My_Res", "my_res"} { + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: rd, Props: map[string]string{"Aux/spelling": rd}}); err != nil { + t.Fatalf("Create definition %q: %v", rd, err) + } + } + + for _, rd := range []string{"My_Res", "my_res"} { + got, err := s.ResourceDefinitions().Get(ctx, rd) + if err != nil { + t.Fatalf("Get %q: %v", rd, err) + } + + if got.Props["Aux/spelling"] != rd { + t.Errorf("Get %q returned the definition created as %q", rd, got.Props["Aux/spelling"]) + } + } +} + +func testChildrenOfDefinitionUnderAnySpelling(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "Pvc-Mix"}); err != nil { + t.Fatalf("Create definition: %v", err) + } + + // Written in a spelling other than the definition's own. + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "PVC-MIX", NodeName: "n1"}); err != nil { + t.Fatalf("Create replica: %v", err) + } + + if err := s.Snapshots().Create(ctx, &apiv1.Snapshot{ + Name: "snap-mix", ResourceName: "PVC-MIX", Nodes: []string{"n1"}, + }); err != nil { + t.Fatalf("Create snapshot: %v", err) + } + + for _, asked := range []string{"Pvc-Mix", "pvc-mix", "PVC-MIX"} { + replicas, err := s.Resources().ListByDefinition(ctx, asked) + if err != nil { + t.Fatalf("ListByDefinition %q: %v", asked, err) + } + + if len(replicas) != 1 { + t.Errorf("ListByDefinition %q found %d replica(s), want 1", asked, len(replicas)) + } + + snaps, err := s.Snapshots().ListByDefinition(ctx, asked) + if err != nil { + t.Fatalf("Snapshots ListByDefinition %q: %v", asked, err) + } + + if len(snaps) != 1 { + t.Errorf("Snapshots ListByDefinition %q found %d snapshot(s), want 1", asked, len(snaps)) + } + } +} + +func testListByNodeUnderAnySpelling(t *testing.T, newStore Factory) { + t.Helper() + + s := newStore(t) + ctx := t.Context() + + if err := s.Nodes().Create(ctx, &apiv1.Node{Name: "Node-Mix", Type: "SATELLITE"}); err != nil { + t.Fatalf("Create node: %v", err) + } + + // Written in a spelling other than the node's own. + if err := s.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-node-mix", NodeName: "NODE-MIX"}); err != nil { + t.Fatalf("Create replica: %v", err) + } + + for _, asked := range []string{"Node-Mix", "node-mix", "NODE-MIX"} { + got, err := s.Resources().ListByNode(ctx, asked) + if err != nil { + t.Fatalf("ListByNode %q: %v", asked, err) + } + + if len(got) != 1 { + t.Errorf("ListByNode %q found %d replica(s), want 1", asked, len(got)) + } + } +} diff --git a/pkg/validate/layers.go b/pkg/validate/layers.go index bc47d1e6..17f57400 100644 --- a/pkg/validate/layers.go +++ b/pkg/validate/layers.go @@ -82,6 +82,28 @@ func LayerStack(layers []string) error { return layerStackOrder(normalized) } +// CanonicalLayerStack validates a stack the way LayerStack does and returns +// it in canonical case, the spelling every reader compares against. An empty +// stack comes back as nil. Write paths store what this returns, so a stack +// typed as `drbd,storage` is the same stored value as `DRBD,STORAGE`. +func CanonicalLayerStack(layers []string) ([]string, error) { + if len(layers) == 0 { + return nil, nil + } + + normalized, err := NormalizeLayerStack(layers) + if err != nil { + return nil, err + } + + err = layerStackOrder(normalized) + if err != nil { + return nil, err + } + + return normalized, nil +} + // NormalizeLayerStack upper-cases each entry and rejects anything outside the // allowlist. The original token is surfaced in the error so an operator can // grep their input for the offending entry verbatim. diff --git a/pkg/validate/resource_delete.go b/pkg/validate/resource_delete.go index 04da14e7..f28cf4c8 100644 --- a/pkg/validate/resource_delete.go +++ b/pkg/validate/resource_delete.go @@ -22,6 +22,7 @@ import ( "errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) // ErrLastSyncSourceDelete refuses removing the only replica a mid-sync peer @@ -67,7 +68,7 @@ func MidSyncDeleteRefusal(target *apiv1.Resource, siblings []apiv1.Resource) boo for i := range siblings { sib := &siblings[i] - if sib.NodeName == target.NodeName || !ResourceIsDiskful(sib) { + if naming.Same(sib.NodeName, target.NodeName) || !ResourceIsDiskful(sib) { continue } diff --git a/pkg/validate/snapshot.go b/pkg/validate/snapshot.go index d212aa9a..a9a7afa2 100644 --- a/pkg/validate/snapshot.go +++ b/pkg/validate/snapshot.go @@ -22,6 +22,8 @@ import ( "errors" "fmt" "strings" + + "github.com/cozystack/blockstor/pkg/naming" ) // ErrRestoreNodeLacksSnapshot refuses a restore onto a node that does not @@ -70,9 +72,11 @@ func RestoreNodesMissingSnapshot(named, snapshotNodes []string) []string { return nil } + // Both lists are read by the CR name a node maps to: the snapshot lists + // its nodes as registered, Node-1, and the operator may name node-1. holds := make(map[string]struct{}, len(snapshotNodes)) for _, n := range snapshotNodes { - holds[n] = struct{}{} + holds[naming.Key(n)] = struct{}{} } seen := make(map[string]struct{}, len(named)) @@ -83,13 +87,14 @@ func RestoreNodesMissingSnapshot(named, snapshotNodes []string) []string { continue } - if _, dup := seen[n]; dup { + key := naming.Key(n) + if _, dup := seen[key]; dup { continue } - seen[n] = struct{}{} + seen[key] = struct{}{} - if _, ok := holds[n]; !ok { + if _, ok := holds[key]; !ok { missing = append(missing, n) } } diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go index d728fdbe..213f0e78 100644 --- a/pkg/validate/validate_test.go +++ b/pkg/validate/validate_test.go @@ -345,3 +345,18 @@ func TestRestoreRefusalKeepsTheOperatorsOrder(t *testing.T) { t.Errorf("missing = %v, want %v (input order, deduped, empties dropped)", missing, want) } } + +// The snapshot lists its nodes as registered, Node-1, and the operator may +// name them otherwise: node-1 holds the snapshot, and two spellings of one +// missing node are one refusal. +func TestRestoreReadsTheSnapshotNodesInAnotherSpelling(t *testing.T) { + t.Parallel() + + missing := validate.RestoreNodesMissingSnapshot( + []string{"node-1", "NODE-2", "node-2"}, []string{"Node-1"}) + + want := []string{"NODE-2"} + if !slices.Equal(missing, want) { + t.Errorf("missing = %v, want %v", missing, want) + } +} diff --git a/site/content/en/docs/migration.md b/site/content/en/docs/migration.md index 5f489f64..52e34320 100644 --- a/site/content/en/docs/migration.md +++ b/site/content/en/docs/migration.md @@ -55,14 +55,14 @@ Not converted, and listed in the tool's report: 4. **Take pool snapshots.** A recursive `zfs snapshot` of every pool gives you a rollback point that does not depend on either control plane. -5. **Check the volume names on disk.** Blockstor finds existing volumes by name: `/_`, for example `data/pvc-1234_00000`. If the name does not match exactly, blockstor creates a new empty volume next to the real one. Replicated volumes would heal from their peers, but a volume with a single replica and no DRBD would come up empty. On every node, compare the list of volumes with the names the converted manifests expect: +5. **Check the volume names on disk.** Blockstor finds existing volumes by name: `/_`, for example `data/pvc-1234_00000`. The CR name is the definition's `metadata.name` in the converted manifests: its LINSTOR name in lowercase, or, for a name that is not a valid Kubernetes name once lowercased, a hashed slug such as `35d21682-my-res` for `my_res`. A volume of such a definition never matches its LINSTOR name on disk, so rename the definition or the volume before you switch over. If the name does not match exactly, blockstor creates a new empty volume next to the real one. Replicated volumes would heal from their peers, but a volume with a single replica and no DRBD would come up empty. On every node, compare the list of volumes with the names the converted manifests expect: ```sh zfs list -H -o name -t volume | sort zfs list -H -o name -t snapshot | sort ``` - Snapshots are found as `/_00000@`. Do not continue while any single-replica volume is missing under its expected name. + Snapshots are found as `/_00000@`. Do not continue while any single-replica volume is missing under its expected name. 6. **Rehearse on staging.** Run the whole procedure on a copy of the pools. Check that every pool registers, every replica reaches `UpToDate` without a resync, and no new empty volumes appear. diff --git a/tests/integration/group_k_test.go b/tests/integration/group_k_test.go index b2476c3d..5fd5b196 100644 --- a/tests/integration/group_k_test.go +++ b/tests/integration/group_k_test.go @@ -430,12 +430,13 @@ func TestGroupKWFPoolDestroyedDropsFromPlacer(t *testing.T) { stack.Satellite.SimulatePoolMissing(deadNode, deadPool) // Give the satellite mock a tick or two so PoolMissing actually - // lands on the CRD status. Without this the placer reads a clean - // SP and the test is racy. + // lands on the CRD status, and wait for it where the placer reads + // it: the manager's cache. The apiserver has it a moment before the + // cache does, and a placement in between lands on the dead pool. harness.Eventually(t, wfEventually, func() bool { var sp blockstoriov1alpha1.StoragePool - err := stack.Env.Client.Get(context.Background(), + err := stack.Manager.GetClient().Get(context.Background(), types.NamespacedName{Name: deadPool + "." + deadNode}, &sp) if err != nil { return false @@ -450,8 +451,15 @@ func TestGroupKWFPoolDestroyedDropsFromPlacer(t *testing.T) { // `rd create` in the pinned linstor-client 1.27.1 has no // `--storage-pool` flag — the pool constraint rides on the // autoplace call below, exactly like the operator flow. + // + // The definition sits in a group of its own that carries the same pool + // constraint: the group's rebalance pass may place a new definition + // before this autoplace does, with the group's filter, and the + // assertion is about any placement skipping the missing pool. rdName := "wf-pool-gone" - cli.Run(t, "resource-definition", "create", rdName, "--resource-group", wfRG) + rgName := "wf-pool-gone-rg" + cli.Run(t, "resource-group", "create", rgName, "--place-count", "2", "--storage-pool", deadPool) + cli.Run(t, "resource-definition", "create", rdName, "--resource-group", rgName) cli.Run(t, "volume-definition", "create", rdName, "4M") cli.Run(t, "resource", "create", rdName, "--auto-place", "2", "--storage-pool", deadPool) diff --git a/tests/integration/harness/fixtures.go b/tests/integration/harness/fixtures.go index 94181aea..24cd195f 100644 --- a/tests/integration/harness/fixtures.go +++ b/tests/integration/harness/fixtures.go @@ -23,6 +23,7 @@ package harness import ( "context" "testing" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -102,6 +103,36 @@ func SeedThreeNodeCluster(t *testing.T, stack *Stack) { } seedDefaultResourceGroup(ctx, t, cli) + + waitForSeededCapacity(t, stack) +} + +// waitForSeededCapacity holds a test until the mock satellite has stamped +// capacity on every seeded pool and the manager's cache, which the placer +// reads, has seen it. A placement before that finds no pool with free +// space, defers itself to the shortfall replay, and a test waiting for its +// replicas times out. +func waitForSeededCapacity(t *testing.T, stack *Stack) { + t.Helper() + + want := len(FixtureNodes()) * len(FixtureProviders()) + + Eventually(t, seedCapacityTimeout, func() bool { + var pools blockstoriov1alpha1.StoragePoolList + if err := stack.Manager.GetClient().List(context.Background(), &pools); err != nil { + return false + } + + stamped := 0 + + for i := range pools.Items { + if pools.Items[i].Status.FreeCapacity > 0 { + stamped++ + } + } + + return stamped >= want + }, "the mock satellite never stamped capacity on the seeded pools") } func seedNode(ctx context.Context, t *testing.T, cli client.Client, name string) { @@ -128,6 +159,10 @@ func seedNode(ctx context.Context, t *testing.T, cli client.Client, name string) } } +// seedCapacityTimeout bounds the wait for the first capacity stamp; the +// mock satellite ticks every satelliteTickInterval. +const seedCapacityTimeout = 10 * time.Second + func seedStoragePool(ctx context.Context, t *testing.T, cli client.Client, node, pool, kind string) { t.Helper() diff --git a/tests/integration/harness/seed_capacity_test.go b/tests/integration/harness/seed_capacity_test.go new file mode 100644 index 00000000..2eaba97c --- /dev/null +++ b/tests/integration/harness/seed_capacity_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package harness + +import ( + "context" + "testing" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" +) + +// A test that places right after seeding finds every pool with free space +// in the cache the placer reads; before the satellite's first capacity +// stamp it finds none, and its placement is deferred out of the test's +// reach. +func TestSeededPoolsHaveCapacityWhereThePlacerReads(t *testing.T) { + stack := StartStack(t) + SeedThreeNodeCluster(t, stack) + + var pools blockstoriov1alpha1.StoragePoolList + if err := stack.Manager.GetClient().List(context.Background(), &pools); err != nil { + t.Fatalf("list pools: %v", err) + } + + if want := len(FixtureNodes()) * len(FixtureProviders()); len(pools.Items) < want { + t.Fatalf("pools in the cache: %d, want %d", len(pools.Items), want) + } + + for i := range pools.Items { + if pools.Items[i].Status.FreeCapacity == 0 { + t.Errorf("pool %s has no free capacity when seeding returns", pools.Items[i].Name) + } + } +} diff --git a/tests/integration/rd_delete_mixed_case_test.go b/tests/integration/rd_delete_mixed_case_test.go new file mode 100644 index 00000000..731320ed --- /dev/null +++ b/tests/integration/rd_delete_mixed_case_test.go @@ -0,0 +1,115 @@ +//go:build integration + +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + k8sstore "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/tests/integration/harness" +) + +// `rd d PVC-X` over a definition stored as `pvc-x`: the snapshot gate and the +// cascade listed under the typed spelling and found nothing, and the folded +// Delete then took the definition, leaving its replicas and snapshots behind. +// This needs the real API server, since only its field selector compares +// the stored spelling exactly. +func TestRDDeleteInAnotherCaseSeesSnapshotsAndReplicas(t *testing.T) { + stack := harness.StartStack(t) + ctx := context.Background() + st := k8sstore.New(stack.Env.Client) + + const rd = "pvc-mixcase" + + status, body := b434HTTP(t, http.MethodPost, stack.RestURL+"/v1/resource-definitions", + map[string]any{"resource_definition": map[string]any{"name": rd}}) + if status != http.StatusCreated { + t.Fatalf("rd create: %d %s", status, body) + } + + res := &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: rd + ".node-1"}, + Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: rd, NodeName: "node-1"}, + } + if err := stack.Env.Client.Create(ctx, res); err != nil { + t.Fatalf("seed replica: %v", err) + } + + snap := &blockstoriov1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{Name: rd + ".snap-1"}, + Spec: blockstoriov1alpha1.SnapshotSpec{ResourceDefinitionName: rd, SnapshotName: "snap-1"}, + } + if err := stack.Env.Client.Create(ctx, snap); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + waitForMixedCase(t, "the cache to see the replica and the snapshot", func() bool { + replicas, rErr := st.Resources().ListByDefinition(ctx, rd) + snaps, sErr := st.Snapshots().ListByDefinition(ctx, rd) + + return rErr == nil && sErr == nil && len(replicas) == 1 && len(snaps) == 1 + }) + + // With a snapshot on it, the delete is refused in any spelling. + resp := rgcHTTPDelete(t, stack.RestURL+"/v1/resource-definitions/"+strings.ToUpper(rd)) + _ = resp.Body.Close() + + if resp.StatusCode < 400 { + t.Fatalf("rd d %s answered %d over a definition with a snapshot", strings.ToUpper(rd), resp.StatusCode) + } + + if _, err := st.ResourceDefinitions().Get(ctx, rd); err != nil { + t.Fatalf("the refused delete took the definition anyway: %v", err) + } + + // Without the snapshot, the delete in another case takes the replica too. + if err := stack.Env.Client.Delete(ctx, snap); err != nil { + t.Fatalf("drop snapshot: %v", err) + } + + waitForMixedCase(t, "the cache to drop the snapshot", func() bool { + snaps, err := st.Snapshots().ListByDefinition(ctx, rd) + + return err == nil && len(snaps) == 0 + }) + + resp = rgcHTTPDelete(t, stack.RestURL+"/v1/resource-definitions/"+strings.ToUpper(rd)) + _ = resp.Body.Close() + + if resp.StatusCode >= 400 { + t.Fatalf("rd d %s: %d", strings.ToUpper(rd), resp.StatusCode) + } + + waitForMixedCase(t, "the replica to be reaped with its definition", func() bool { + var live blockstoriov1alpha1.Resource + + err := stack.Env.Client.Get(ctx, types.NamespacedName{Name: rd + ".node-1"}, &live) + + return err != nil || live.DeletionTimestamp != nil + }) +} + +func waitForMixedCase(t *testing.T, what string, cond func() bool) { + t.Helper() + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + + time.Sleep(100 * time.Millisecond) + } + + t.Fatalf("timed out waiting for %s", what) +}