From 8363c280bd057f7dabae91cd302609dabe31a8cb Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 12:22:40 +0100 Subject: [PATCH 01/51] fix(layer-stack): store definition and group stacks in canonical case rd create, rg create and rg modify validated a layer stack case-insensitively and stored it as the caller spelled it, and so did the CLI. Some readers compare the stored value exactly: the satellite controller's DRBD check read `drbd,storage` as a stack without DRBD, so allocation waited and requeued while the apply path brought DRBD up. Every write path now stores the canonical spelling, the CSI layer_list paths of autoplace, explicit resource create and make-available included. The satellite check folds case for objects written before this or applied by hand, and so does rg modify's rebalance check, so a modify that resends a group's lowercase stack in canonical case rebalances nothing. Fixes #204 Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/cli/layer_list_case_test.go | 40 +++ internal/cli/write_more.go | 10 +- pkg/rest/autoplace.go | 8 +- pkg/rest/layer_stack_case_test.go | 227 ++++++++++++++++++ pkg/rest/layer_validation.go | 34 +++ pkg/rest/resource_definitions.go | 4 +- pkg/rest/resource_groups.go | 10 +- .../controllers/rd_needs_drbd_case_test.go | 43 ++++ pkg/satellite/controllers/resource.go | 2 +- pkg/validate/layers.go | 22 ++ 10 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 internal/cli/layer_list_case_test.go create mode 100644 pkg/rest/layer_stack_case_test.go create mode 100644 pkg/satellite/controllers/rd_needs_drbd_case_test.go 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/write_more.go b/internal/cli/write_more.go index 34f719a3..b9f48f67 100644 --- a/internal/cli/write_more.go +++ b/internal/cli/write_more.go @@ -65,13 +65,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 diff --git a/pkg/rest/autoplace.go b/pkg/rest/autoplace.go index 0830c5ce..36b4a699 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -542,7 +542,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 +557,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 }) @@ -1540,7 +1540,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 @@ -2507,7 +2507,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 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/resource_definitions.go b/pkg/rest/resource_definitions.go index 1c0b772f..60d0bd58 100644 --- a/pkg/rest/resource_definitions.go +++ b/pkg/rest/resource_definitions.go @@ -317,7 +317,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()) @@ -851,7 +851,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_groups.go b/pkg/rest/resource_groups.go index bac24c58..dec8f246 100644 --- a/pkg/rest/resource_groups.go +++ b/pkg/rest/resource_groups.go @@ -27,6 +27,7 @@ import ( "maps" "net/http" "slices" + "strings" "time" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" @@ -173,7 +174,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()) @@ -413,7 +414,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 +1041,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/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..147e68fb 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -1245,7 +1245,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 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. From bfd7beec2e2208b0ebfb13c19ee07271cd1fc370 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 12:22:51 +0100 Subject: [PATCH 02/51] fix(store): store a replica's parents under their CR names LINSTOR names are case-insensitive and the store addresses one object through the folded CRD slug, but a replica or snapshot keeps its definition's name in spec, and the field selector compares it exactly. A listing asked in another spelling came back empty. On `rd d PVC-X` over `pvc-x` that let the snapshot gate pass and the cascade reap nothing, and the folded delete then took the definition and left its replicas and snapshots behind. The node-scoped listing had the same gap for node names. The controller and the satellite read the same spec values as CR names: they Get the definition and the node by them, compare them with CR names, and name the DRBD resource and its volumes after the definition. A replica created as `r c Pvc-Mix n1` stored `Pvc-Mix`, was never applied, and was deleted as an orphan by the controller once the satellite had stamped its finalizer. The Kubernetes store now writes the definition's and the node's CR names into a new replica's spec, and the definition's into a new snapshot's. The registered spellings are kept in annotations that only the wire conversion reads, so the REST surface still shows `Pvc-Mix`, and updates keep the stored names. Listings ask under the given, the folded and the registered spelling, so rows written before this, or adopted from LINSTOR, are still found. The in-memory store folds its comparisons to match. Fixes #194 Fixes #212 Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/inmemory_resource.go | 4 +- pkg/store/inmemory_snapshot.go | 2 +- .../k8s/folded_spelling_children_test.go | 107 ++++++++++ pkg/store/k8s/resources.go | 84 +++++++- pkg/store/k8s/snapshots.go | 100 +++++---- pkg/store/k8s/spellings.go | 195 ++++++++++++++++++ pkg/store/k8s/stored_cr_names_test.go | 168 +++++++++++++++ pkg/store/store.go | 13 +- pkg/store/storetest/storetest.go | 80 +++++++ .../integration/rd_delete_mixed_case_test.go | 115 +++++++++++ 10 files changed, 812 insertions(+), 56 deletions(-) create mode 100644 pkg/store/k8s/folded_spelling_children_test.go create mode 100644 pkg/store/k8s/spellings.go create mode 100644 pkg/store/k8s/stored_cr_names_test.go create mode 100644 tests/integration/rd_delete_mixed_case_test.go diff --git a/pkg/store/inmemory_resource.go b/pkg/store/inmemory_resource.go index 8d5e5f34..3869fd0b 100644 --- a/pkg/store/inmemory_resource.go +++ b/pkg/store/inmemory_resource.go @@ -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 FoldName(s.m[k].NodeName) == FoldName(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 FoldName(s.m[k].Name) == FoldName(rdName) { out = append(out, s.m[k]) } } diff --git a/pkg/store/inmemory_snapshot.go b/pkg/store/inmemory_snapshot.go index cc627bc8..3aeb18ca 100644 --- a/pkg/store/inmemory_snapshot.go +++ b/pkg/store/inmemory_snapshot.go @@ -66,7 +66,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 FoldName(s.m[k].ResourceName) == FoldName(rdName) { out = append(out, s.m[k]) } } 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..d0546f0a --- /dev/null +++ b/pkg/store/k8s/folded_spelling_children_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 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) + } +} diff --git a/pkg/store/k8s/resources.go b/pkg/store/k8s/resources.go index f1e933fc..8c88d9b4 100644 --- a/pkg/store/k8s/resources.go +++ b/pkg/store/k8s/resources.go @@ -87,8 +87,10 @@ 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() + + out, err := s.listUnderSpellings(ctx, reader, FieldResourceNodeName, nodeSpellings(ctx, reader, node), + func(r *crdv1alpha1.Resource, spelling string) bool { return r.Spec.NodeName == spelling }) if err != nil { return nil, err } @@ -176,7 +178,25 @@ 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 := storedRDName(ctx, s.c, in.Name) + node := storedNodeName(ctx, s.c, in.NodeName) + + shown := *in + shown.Name = rd.display + shown.NodeName = node.display + + crd := wireToCRDResource(&shown) + 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 { @@ -231,11 +251,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 @@ -250,7 +274,7 @@ func (s *resources) Update(ctx context.Context, in *apiv1.Resource) error { // `lookupVolumeNumbers` during the harm window leaks the backing // LV / zvol. Mirrors the RD / RG / Snapshot stores. mergeUserAnnotationsInto(&existing.ObjectMeta, in.Annotations, - crdv1alpha1.ResourceAnnotationVolumeNumbers) + crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) err = s.c.Update(ctx, &existing) if err != nil { @@ -313,11 +337,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) @@ -329,7 +356,7 @@ func (s *resources) PatchResourceSpec(ctx context.Context, rdName, node string, // path and protects against future closures that explicitly // reset live.Annotations. mergeUserAnnotationsInto(&existing.ObjectMeta, wire.Annotations, - crdv1alpha1.ResourceAnnotationVolumeNumbers) + crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) return s.c.Patch(ctx, &existing, ctrlclient.MergeFromWithOptions(base, ctrlclient.MergeFromWithOptimisticLock{})) }), "patch Resource %s/%s", rdName, node) @@ -545,8 +572,8 @@ 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 }) + out, err := s.listUnderSpellings(ctx, reader, FieldResourceDefinitionName, rdSpellings(ctx, reader, rdName), + func(r *crdv1alpha1.Resource, spelling string) bool { return r.Spec.ResourceDefinitionName == spelling }) if err != nil { return nil, err } @@ -632,8 +659,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 +681,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 +1122,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..088a367f 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,7 +148,17 @@ 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 := storedRDName(ctx, s.c, in.ResourceName) + + shown := *in + shown.ResourceName = rd.display + + crd := wireToCRDSnapshot(&shown) + crd.Spec.ResourceDefinitionName = rd.crName + crd.Labels[LabelResourceDefinition] = rd.crName + rd.annotate(&crd.ObjectMeta, AnnotationLinstorRDName) err := s.c.Create(ctx, crd) if err != nil { @@ -238,13 +221,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, in.Annotations, AnnotationLinstorRDName) return s.c.Update(ctx, &existing) }), "update Snapshot %s/%s", in.ResourceName, in.Name) @@ -315,6 +302,47 @@ 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 := rdSpellings(ctx, reader, rdName) + + 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 +404,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 +617,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 +629,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/spellings.go b/pkg/store/k8s/spellings.go new file mode 100644 index 00000000..7231a69d --- /dev/null +++ b/pkg/store/k8s/spellings.go @@ -0,0 +1,195 @@ +// 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" + + 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" + "github.com/cozystack/blockstor/pkg/store" +) + +// 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, the folded one, and the +// registered one. + +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" +) + +// 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 { + if found, ok := lookupRD(ctx, reader, rdName); ok { + return found + } + + return storedName{crName: Name(rdName), display: rdName} +} + +// 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 { + if found, ok := lookupNode(ctx, reader, node); ok { + return found + } + + return storedName{crName: Name(node), display: node} +} + +// 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) { + var crd crdv1alpha1.ResourceDefinition + + err := reader.Get(ctx, types.NamespacedName{Name: Name(rdName)}, &crd) + if err != nil { + return storedName{}, false + } + + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true +} + +func lookupNode(ctx context.Context, reader ctrlclient.Reader, node string) (storedName, bool) { + var crd crdv1alpha1.Node + + err := reader.Get(ctx, types.NamespacedName{Name: Name(node)}, &crd) + if err != nil { + return storedName{}, false + } + + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true +} + +// rdSpellings and nodeSpellings answer the spellings to ask under. A failed +// lookup of the registered object only drops its spellings: the listing +// still asks under the given and the folded ones, which is what it did before. +func rdSpellings(ctx context.Context, reader ctrlclient.Reader, rdName string) []string { + found, ok := lookupRD(ctx, reader, rdName) + + return spellingSet(rdName, found, ok) +} + +func nodeSpellings(ctx context.Context, reader ctrlclient.Reader, node string) []string { + found, ok := lookupNode(ctx, reader, node) + + return spellingSet(node, found, ok) +} + +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(store.FoldName(given)) + add(Name(given)) + + if haveFound { + add(found.crName) + add(found.display) + } + + return out +} + +// 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/stored_cr_names_test.go b/pkg/store/k8s/stored_cr_names_test.go new file mode 100644 index 00000000..3a565040 --- /dev/null +++ b/pkg/store/k8s/stored_cr_names_test.go @@ -0,0 +1,168 @@ +// 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) + } + + got.Props = map[string]string{"Aux/k": "v"} + if err := s.Update(ctx, &got); err != nil { + t.Fatalf("Update: %v", err) + } + + assertSpec("update") + + if err := s.PatchResourceSpec(ctx, "Pvc-Mix", "Node-1", func(r *apiv1.Resource) error { + r.Props["Aux/k"] = "w" + + return nil + }); err != nil { + t.Fatalf("PatchResourceSpec: %v", err) + } + + assertSpec("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"} + if err := s.Update(ctx, &got); err != nil { + t.Fatalf("Update: %v", err) + } + + stored("update") +} diff --git a/pkg/store/store.go b/pkg/store/store.go index 846637c6..e26536e9 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -325,13 +325,12 @@ type ResourceStore interface { // 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. +// The Kubernetes store now writes the folded side: a replica or snapshot it +// creates stores its parents' CR names in spec and keeps the registered +// spelling in an annotation that only the wire conversion reads, so clients +// still see the spelling they compare (pkg/store/k8s/spellings.go). The +// listings also ask under the registered spelling of a definition, so the +// shapes left are rows written before that, or adopted, in a fourth case. func FoldName(name string) string { return strings.ToLower(name) } diff --git a/pkg/store/storetest/storetest.go b/pkg/store/storetest/storetest.go index ecfc1a1a..67143753 100644 --- a/pkg/store/storetest/storetest.go +++ b/pkg/store/storetest/storetest.go @@ -734,6 +734,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 @@ -2261,3 +2272,72 @@ func testReferencesOnNodeUnderAnotherSpelling(t *testing.T, newStore Factory) { "the refusal would pass and the node would go with a replica on it", len(replicas)) } } + +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/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) +} From 0d2a2d9fc650925f657c6bfc2e5094ea443ffedb Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 12:23:02 +0100 Subject: [PATCH 03/51] fix(controller,satellite): fold the names a replica may spell otherwise The store writes a replica's definition and node as their CR names, but a replica written or adopted before the store and linstor-migrate did that may carry the display spellings, Pvc-Mix and Node-1 say. A storage pool keeps the node spelling it was created with. These compares now fold both sides the way the store addresses the object: on the controller, the tiebreaker count, resize-pending stamping, the hosting-node list, the peer list, node-id allocation, the sibling fan-out, the snapshot's sibling check and the definition a replica event enqueues; on the satellite, the own-node checks for replicas and pools in the reconcilers, the sweepers and device discovery. Lookups that Get a parent by the stored value, and the other compares of the stored names, are unchanged and rely on the CR names the store writes. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../controller/mixed_case_replicas_test.go | 74 +++++++++++++++++++ internal/controller/replica_of.go | 40 ++++++++++ internal/controller/resource_controller.go | 8 +- .../resourcedefinition_controller.go | 8 +- internal/controller/snapshot_timeout.go | 2 +- .../controllers/metadata_created_backfill.go | 2 +- pkg/satellite/controllers/node_name.go | 31 ++++++++ .../controllers/node_name_case_test.go | 49 ++++++++++++ pkg/satellite/controllers/physicaldevice.go | 4 +- .../controllers/physicaldevice_discovery.go | 2 +- pkg/satellite/controllers/resource.go | 12 +-- pkg/satellite/controllers/storage_sweeper.go | 2 +- pkg/satellite/controllers/storagepool.go | 2 +- pkg/satellite/controllers/sweeper.go | 2 +- 14 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 internal/controller/mixed_case_replicas_test.go create mode 100644 internal/controller/replica_of.go create mode 100644 pkg/satellite/controllers/node_name.go create mode 100644 pkg/satellite/controllers/node_name_case_test.go diff --git a/internal/controller/mixed_case_replicas_test.go b/internal/controller/mixed_case_replicas_test.go new file mode 100644 index 00000000..2c7159e2 --- /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/store/k8s" +) + +func mixedCaseReplica(rd, node string) *blockstoriov1alpha1.Resource { + return &blockstoriov1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(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(), k8s.Name("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 != k8s.Name("Pvc-Mix") { + t.Errorf("enqueued %v, want %s", reqs, k8s.Name("Pvc-Mix")) + } +} diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go new file mode 100644 index 00000000..824a28e5 --- /dev/null +++ b/internal/controller/replica_of.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 controller + +import ( + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" + "github.com/cozystack/blockstor/pkg/store/k8s" +) + +// isReplicaOf reports whether res belongs to the definition stored as +// rdName, the CR name. 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 k8s.Name(res.Spec.ResourceDefinitionName) == rdName +} + +// sameDefinition reports whether two spellings of a definition's name, as +// two replicas or a replica and a snapshot carry them, name the same +// definition: the one the store keeps under their common folded name. +func sameDefinition(a, b string) bool { + return k8s.Name(a) == k8s.Name(b) +} diff --git a/internal/controller/resource_controller.go b/internal/controller/resource_controller.go index 53a0597b..9ffdbb6c 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -336,7 +336,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 sameDefinition(resList.Items[i].Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { peers = append(peers, resList.Items[i]) } } @@ -1761,7 +1761,7 @@ 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 } @@ -2215,7 +2215,7 @@ func (r *ResourceReconciler) collectTakenNodeIDs(ctx context.Context, target *bl continue } - if res.Spec.ResourceDefinitionName != target.Spec.ResourceDefinitionName { + if !sameDefinition(res.Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { continue } @@ -2284,7 +2284,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 !sameDefinition(resList.Items[i].Spec.ResourceDefinitionName, rdName) { continue } diff --git a/internal/controller/resourcedefinition_controller.go b/internal/controller/resourcedefinition_controller.go index a501ed7d..5670b9f6 100644 --- a/internal/controller/resourcedefinition_controller.go +++ b/internal/controller/resourcedefinition_controller.go @@ -697,7 +697,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 } @@ -1669,7 +1669,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 +1923,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: k8s.Name(res.Spec.ResourceDefinitionName)}}, } } diff --git a/internal/controller/snapshot_timeout.go b/internal/controller/snapshot_timeout.go index 7f623f11..146eedc2 100644 --- a/internal/controller/snapshot_timeout.go +++ b/internal/controller/snapshot_timeout.go @@ -332,7 +332,7 @@ func (r *SnapshotReconciler) siblingReplicasUpToDate( for i := range resList.Items { res := &resList.Items[i] - if res.Spec.ResourceDefinitionName != snap.Spec.ResourceDefinitionName { + if !sameDefinition(res.Spec.ResourceDefinitionName, snap.Spec.ResourceDefinitionName) { continue } diff --git a/pkg/satellite/controllers/metadata_created_backfill.go b/pkg/satellite/controllers/metadata_created_backfill.go index edfa786e..71263f42 100644 --- a/pkg/satellite/controllers/metadata_created_backfill.go +++ b/pkg/satellite/controllers/metadata_created_backfill.go @@ -140,7 +140,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 !isNode(res.Spec.NodeName, b.NodeName) { return false } diff --git a/pkg/satellite/controllers/node_name.go b/pkg/satellite/controllers/node_name.go new file mode 100644 index 00000000..b2a00d43 --- /dev/null +++ b/pkg/satellite/controllers/node_name.go @@ -0,0 +1,31 @@ +// 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 "github.com/cozystack/blockstor/pkg/store/k8s" + +// isNode reports whether name, as a replica or a pool spells its node, is +// node. The store addresses a Node under its folded name, so a Node +// registered as Node-1 and a satellite started as node-1 are the same +// node. A storage pool keeps the node spelling it was created with, and a +// replica written or adopted before the store and linstor-migrate stored +// the node's CR name may carry Node-1: either is this satellite's. +func isNode(name, node string) bool { + return k8s.Name(name) == k8s.Name(node) +} 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/physicaldevice.go b/pkg/satellite/controllers/physicaldevice.go index f62d7e48..a117d5c3 100644 --- a/pkg/satellite/controllers/physicaldevice.go +++ b/pkg/satellite/controllers/physicaldevice.go @@ -348,7 +348,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 p.Spec.PoolName == poolName && isNode(p.Spec.NodeName, r.Config.NodeName) { target = p break @@ -636,7 +636,7 @@ 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 { + isNode(poolList.Items[i].Spec.NodeName, r.Config.NodeName) { return true, nil } } diff --git a/pkg/satellite/controllers/physicaldevice_discovery.go b/pkg/satellite/controllers/physicaldevice_discovery.go index 7c9a53be..2dbe14ed 100644 --- a/pkg/satellite/controllers/physicaldevice_discovery.go +++ b/pkg/satellite/controllers/physicaldevice_discovery.go @@ -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 !isNode(pool.Spec.NodeName, p.NodeName) { continue } diff --git a/pkg/satellite/controllers/resource.go b/pkg/satellite/controllers/resource.go index 147e68fb..3ca3eb29 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -162,7 +162,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 !isNode(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 @@ -421,7 +421,7 @@ 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 { + isNode(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -1054,7 +1054,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 isNode(res.Spec.NodeName, r.Config.NodeName) { return nil } @@ -1068,7 +1068,7 @@ 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 { + isNode(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -1634,11 +1634,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 isNode(r.Spec.NodeName, nodeName) } if sp, ok := obj.(*blockstoriov1alpha1.StoragePool); ok { - return sp.Spec.NodeName == nodeName + return isNode(sp.Spec.NodeName, nodeName) } return false diff --git a/pkg/satellite/controllers/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index 5496d961..0fafaee6 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -379,7 +379,7 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) for i := range resList.Items { r := &resList.Items[i] - if r.Spec.NodeName != s.NodeName { + if !isNode(r.Spec.NodeName, s.NodeName) { continue } diff --git a/pkg/satellite/controllers/storagepool.go b/pkg/satellite/controllers/storagepool.go index 36e0ebd6..2523db53 100644 --- a/pkg/satellite/controllers/storagepool.go +++ b/pkg/satellite/controllers/storagepool.go @@ -80,7 +80,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 !isNode(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..02b2546e 100644 --- a/pkg/satellite/controllers/sweeper.go +++ b/pkg/satellite/controllers/sweeper.go @@ -601,7 +601,7 @@ func (s *OrphanSweeperRunnable) listOwnedResourceNames(ctx context.Context) (map for i := range list.Items { r := &list.Items[i] - if r.Spec.NodeName != s.NodeName { + if !isNode(r.Spec.NodeName, s.NodeName) { continue } From afde53cccb52760182c2767a2767ff6ca02fea27 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 12:23:02 +0100 Subject: [PATCH 04/51] test(controller,satellite): pin a mixed-case replica through apply A replica of a definition registered as Pvc-Mix, created through the store on a node registered as Node-1, in other spellings: the controller's orphan check finds its definition, the satellite's reconcile gets past the definition into the peer read, the DRBD resource is named pvc-mix on node-1, the port allocator sees a replica written as node-1 before, and the state projection follows the node. sameDefinition is pinned on its own. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/controller/stored_cr_names_test.go | 191 ++++++++++++++++++ .../mixed_case_replica_apply_test.go | 119 +++++++++++ 2 files changed, 310 insertions(+) create mode 100644 internal/controller/stored_cr_names_test.go create mode 100644 pkg/satellite/controllers/mixed_case_replica_apply_test.go diff --git a/internal/controller/stored_cr_names_test.go b/internal/controller/stored_cr_names_test.go new file mode 100644 index 00000000..e03b8005 --- /dev/null +++ b/internal/controller/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 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/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: k8s.Name("Pvc-Mix")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") + + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("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 same name, so the port allocator +// sees the older replica's port when it picks for the new one. With the +// registered spelling Node-1 in spec the exact compare missed it. +func TestTakenPortsSeeAReplicaCreatedInAnotherNodeSpelling(t *testing.T) { + t.Parallel() + + cli := mixedCaseStand(t, interceptor.Funcs{}, [2]string{"pvc-new", "NODE-1"}) + ctx := context.Background() + + port := int32(7001) + + 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") + } +} + +// sameDefinition 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 !sameDefinition("Pvc-Mix", "pvc-mix") { + t.Error("Pvc-Mix and pvc-mix read as two definitions") + } + + if sameDefinition("pvc-mix", "pvc-other") { + t.Error("pvc-mix and pvc-other read as one definition") + } +} 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..a860346b --- /dev/null +++ b/pkg/satellite/controllers/mixed_case_replica_apply_test.go @@ -0,0 +1,119 @@ +// 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/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: k8s.Name("Pvc-Mix")}} + k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") + + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("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()) + } +} From 43ab333c7d67ef884bd734a2aa8f3f1388dd400b Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 12:33:03 +0100 Subject: [PATCH 05/51] fix(linstormigrate): adopt replicas and snapshots under CR names The converter wrote the LINSTOR display spelling of a replica's definition and node, and of a snapshot's definition, into spec. The store now keeps the parents' CR names there and the spelling in an annotation, and the controller and satellite look parents up by the spec value, so a migrated mixed-case replica would miss its own definition. Write what the store writes. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/linstormigrate/convert.go | 24 ++++- pkg/linstormigrate/parent_names_test.go | 120 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 pkg/linstormigrate/parent_names_test.go 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) + } + } +} From 1e211bf54e2e9f4dfb39b110a99d7bfcdc0ffbc8 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 20:29:37 +0100 Subject: [PATCH 06/51] fix(dispatcher): find a peer's node under the CR name its replica stores The peer address, the node's PrefNic and the NetInterface it names were looked up by the node's registered spelling, which a replica stored until now. With the CR name in spec a peer on a node registered as Node-2 or my_node matched no Node and got 0.0.0.0, which the satellite writes into the .res as is. Compare the CR name both sides map to, which also matches rows written before in the registered spelling, and compare the pool's node the same way. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/dispatcher/dispatcher.go | 26 +++-- pkg/dispatcher/node_spelling_test.go | 139 +++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 pkg/dispatcher/node_spelling_test.go diff --git a/pkg/dispatcher/dispatcher.go b/pkg/dispatcher/dispatcher.go index 2462de18..9888df6f 100644 --- a/pkg/dispatcher/dispatcher.go +++ b/pkg/dispatcher/dispatcher.go @@ -567,7 +567,7 @@ 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 !sameNodeName(pools[i].Spec.NodeName, nodeName) { continue } @@ -584,7 +584,7 @@ func lookupPrefNic(nodeName, poolName string, nodes []blockstoriov1alpha1.Node, } for i := range nodes { - if k8s.OriginalName(&nodes[i].ObjectMeta) != nodeName { + if !isNodeCalled(&nodes[i], nodeName) { continue } @@ -600,7 +600,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 !isNodeCalled(&nodes[i], nodeName) { continue } @@ -1020,11 +1020,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 isNodeCalled, 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 !isNodeCalled(&nodes[i], nodeName) { continue } @@ -1126,3 +1126,17 @@ func deriveMinor(rd string) int { return minorBase + int(binary.BigEndian.Uint16(digest[2:4])%minorRange) } + +// isNodeCalled reports whether node is the one a replica names as name. +// A replica stores its node's CR name, and one written before that, or +// adopted, may carry the registered spelling; both map to the node's CR +// name, which is how the store addresses it. +func isNodeCalled(node *blockstoriov1alpha1.Node, name string) bool { + return node.Name == k8s.Name(name) +} + +// sameNodeName compares two node names the way the store addresses a +// node, for rows that store a name rather than the Node itself. +func sameNodeName(a, b string) bool { + return k8s.Name(a) == k8s.Name(b) +} diff --git a/pkg/dispatcher/node_spelling_test.go b/pkg/dispatcher/node_spelling_test.go new file mode 100644 index 00000000..eeac76cd --- /dev/null +++ b/pkg/dispatcher/node_spelling_test.go @@ -0,0 +1,139 @@ +// 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/store/k8s" +) + +const peerEndpoint = "192.0.2.2" + +func registeredNode(registered string, spec blockstoriov1alpha1.NodeSpec) blockstoriov1alpha1.Node { + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(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." + k8s.Name(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: k8s.Name("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: k8s.Name("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(k8s.Name("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) + } +} From 4c7a1f617d716feee19723ee5bc2afd89e7129b0 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 20:30:14 +0100 Subject: [PATCH 07/51] fix(controller): see data on a replica stored in another spelling The layer-stack pin decided whether a definition holds data by an exact compare of the replica's definition name, the one controller compare left unfolded. A replica written before the store kept CR names, as Pvc-Pin with metadata created, did not count, so its definition could take the group's stack over data. Use isReplicaOf like the others. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/controller/resourcedefinition_layer_stack.go | 2 +- internal/controller/resourcedefinition_layer_stack_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/controller/resourcedefinition_layer_stack.go b/internal/controller/resourcedefinition_layer_stack.go index a1423ed4..567796d7 100644 --- a/internal/controller/resourcedefinition_layer_stack.go +++ b/internal/controller/resourcedefinition_layer_stack.go @@ -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 } 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() From 8bda5039b245aa82e900d873da5a4bed04c050a5 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 20:32:04 +0100 Subject: [PATCH 08/51] test(store): pin the spelling annotations and the registered spelling An update or patch whose wire carries any annotation replaces the user keys, and the display spellings must survive it. A row stored under the registered spelling must still be found when asked in another one, which only the registered spelling in the listing's set does. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../k8s/folded_spelling_children_test.go | 48 +++++++++++++++++++ pkg/store/k8s/stored_cr_names_test.go | 23 +++++++++ 2 files changed, 71 insertions(+) diff --git a/pkg/store/k8s/folded_spelling_children_test.go b/pkg/store/k8s/folded_spelling_children_test.go index d0546f0a..2be61589 100644 --- a/pkg/store/k8s/folded_spelling_children_test.go +++ b/pkg/store/k8s/folded_spelling_children_test.go @@ -105,3 +105,51 @@ func TestSnapshotScanFallbackKeepsEverySpelling(t *testing.T) { 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/stored_cr_names_test.go b/pkg/store/k8s/stored_cr_names_test.go index 3a565040..573179a7 100644 --- a/pkg/store/k8s/stored_cr_names_test.go +++ b/pkg/store/k8s/stored_cr_names_test.go @@ -99,15 +99,30 @@ func TestReplicaStoresTheCRNamesOfItsParents(t *testing.T) { 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 { @@ -115,6 +130,7 @@ func TestReplicaStoresTheCRNamesOfItsParents(t *testing.T) { } assertSpec("patch") + assertShown("patch") again, err := s.Get(ctx, "Pvc-Mix", "Node-1") if err != nil || again.Name != "Pvc-Mix" || again.NodeName != "Node-1" { @@ -160,9 +176,16 @@ func TestSnapshotStoresTheCRNameOfItsDefinition(t *testing.T) { } 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) + } } From 5c8051128eb1ffceaf05ca3c98afbb76d165259e Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 20:32:31 +0100 Subject: [PATCH 09/51] docs(migration): name on-disk volumes after the definition's CR name Blockstor names a volume after the definition's CR name, which is the LINSTOR name in lowercase, or a hashed slug for a name that is not a valid Kubernetes name once lowercased. The pages said only "lowercase", which does not hold for a name such as my_res. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- docs/linstor-migration.md | 4 ++-- site/content/en/docs/migration.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/site/content/en/docs/migration.md b/site/content/en/docs/migration.md index 5f489f64..722b7078 100644 --- a/site/content/en/docs/migration.md +++ b/site/content/en/docs/migration.md @@ -55,7 +55,7 @@ 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 From f0959fc7a0081dbe9786f8e4e11cf112748fb4a1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 22:53:36 +0100 Subject: [PATCH 10/51] fix(controller): match a replica's node in every spelling A replica stores its node's CR name, while a snapshot's node list, a storage pool, the wire listing and replicas written before keep the registered spelling. Five controller readers compared the two exactly: the snapshot gate skipped a replica on Node-1 that was not UpToDate, the diskful promotion found no pool on the node, the port allocator missed an older replica's port, and evacuation and the state projection did not reach the node's replicas. They now share one compare that maps both sides to the CR name, the way the store addresses a node. Two case variants of a name outside RFC 1123 hash to two CR names and stay two nodes. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../controller/auto_diskful_controller.go | 2 +- internal/controller/node_controller.go | 2 +- internal/controller/replica_of.go | 12 ++ internal/controller/resource_controller.go | 4 +- .../resource_state_projection_controller.go | 2 +- internal/controller/snapshot_timeout.go | 9 +- internal/controller/stored_cr_names_test.go | 163 +++++++++++++++++- 7 files changed, 178 insertions(+), 16 deletions(-) diff --git a/internal/controller/auto_diskful_controller.go b/internal/controller/auto_diskful_controller.go index ae33113f..da34e660 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.go @@ -326,7 +326,7 @@ func (r *AutoDiskfulReconciler) firstAvailablePool(ctx context.Context, nodeName } for i := range pools { - if pools[i].NodeName != nodeName { + if !sameNode(pools[i].NodeName, nodeName) { continue } diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index 0058191b..bf4ef585 100644 --- a/internal/controller/node_controller.go +++ b/internal/controller/node_controller.go @@ -120,7 +120,7 @@ func (r *NodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } for i := range resList { - if resList[i].NodeName != node.Name { + if !sameNode(resList[i].NodeName, node.Name) { continue } diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go index 824a28e5..da1f50e7 100644 --- a/internal/controller/replica_of.go +++ b/internal/controller/replica_of.go @@ -19,6 +19,8 @@ limitations under the License. package controller import ( + "strings" + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -38,3 +40,13 @@ func isReplicaOf(res *blockstoriov1alpha1.Resource, rdName string) bool { func sameDefinition(a, b string) bool { return k8s.Name(a) == k8s.Name(b) } + +// sameNode reports whether two spellings of a node's name refer to the same +// node. A replica stores its node's CR name, while a snapshot's node list, +// a storage pool and the wire carry the registered spelling, and a replica +// written or adopted before the store stored CR names may carry that +// spelling too. Both sides map to the node's CR name, which is how the +// store addresses it. +func sameNode(a, b string) bool { + return strings.EqualFold(a, b) || k8s.Name(a) == k8s.Name(b) +} diff --git a/internal/controller/resource_controller.go b/internal/controller/resource_controller.go index 9ffdbb6c..5e0a9095 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -483,7 +483,7 @@ func (r *ResourceReconciler) firstAvailablePool(ctx context.Context, nodeName st } for i := range pools { - if pools[i].NodeName != nodeName { + if !sameNode(pools[i].NodeName, nodeName) { continue } @@ -1856,7 +1856,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 !sameNode(res.Spec.NodeName, nodeName) || res.Name == selfName { continue } diff --git a/internal/controller/resource_state_projection_controller.go b/internal/controller/resource_state_projection_controller.go index 89dda83f..bbb26b1d 100644 --- a/internal/controller/resource_state_projection_controller.go +++ b/internal/controller/resource_state_projection_controller.go @@ -268,7 +268,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 !sameNode(resList.Items[i].Spec.NodeName, node.Name) { continue } diff --git a/internal/controller/snapshot_timeout.go b/internal/controller/snapshot_timeout.go index 146eedc2..683e6178 100644 --- a/internal/controller/snapshot_timeout.go +++ b/internal/controller/snapshot_timeout.go @@ -324,11 +324,6 @@ 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] @@ -336,7 +331,9 @@ func (r *SnapshotReconciler) siblingReplicasUpToDate( 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 sameNode(n, res.Spec.NodeName) }) { continue } diff --git a/internal/controller/stored_cr_names_test.go b/internal/controller/stored_cr_names_test.go index e03b8005..c24f6923 100644 --- a/internal/controller/stored_cr_names_test.go +++ b/internal/controller/stored_cr_names_test.go @@ -97,9 +97,9 @@ func TestOrphanCheckFindsTheMixedCaseDefinition(t *testing.T) { // 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 same name, so the port allocator -// sees the older replica's port when it picks for the new one. With the -// registered spelling Node-1 in spec the exact compare missed it. +// 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() @@ -108,10 +108,12 @@ func TestTakenPortsSeeAReplicaCreatedInAnotherNodeSpelling(t *testing.T) { 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, + ResourceDefinitionName: "pvc-old", NodeName: "Node-1", DRBDPort: &port, }, }); err != nil { t.Fatalf("seed the older replica: %v", err) @@ -131,7 +133,7 @@ func TestTakenPortsSeeAReplicaCreatedInAnotherNodeSpelling(t *testing.T) { } 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) + t.Errorf("ports taken on %s = %v, want the %d of the older replica on Node-1", self.Spec.NodeName, taken, port) } } @@ -189,3 +191,154 @@ func TestSameDefinitionFoldsCase(t *testing.T) { 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, k8s.Name("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) + } +} + +// sameNode 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", k8s.Name("Node-1")}, {"my_node", k8s.Name("my_node")}} { + if !sameNode(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 sameNode(pair[0], pair[1]) { + t.Errorf("%s and %s read as one node", pair[0], pair[1]) + } + } +} From ff2b5669409f3252e6da51209e5b8a833ab7e232 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 23:48:25 +0100 Subject: [PATCH 11/51] fix(store): find a storage pool under another spelling of its node A pool keeps the node spelling it was created with, and its lookup compared that spelling with the caller's exactly, so a pool created on node-1 was not found when asked for on Node-1, or the other way round. The lookup now compares the node the way the store addresses it. SameName is that compare, exported for the readers that match a replica's spec, a snapshot's node list, a pool and the wire against each other. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/k8s/pool_node_spelling_test.go | 64 ++++++++++++++++++++++++ pkg/store/k8s/spellings.go | 11 ++++ pkg/store/k8s/storage_pools.go | 12 ++++- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 pkg/store/k8s/pool_node_spelling_test.go 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/spellings.go b/pkg/store/k8s/spellings.go index 7231a69d..2cdd18fd 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -62,6 +62,17 @@ const ( AnnotationLinstorNodeName = "blockstor.io/linstor-node-name" ) +// SameName reports whether two spellings of a LINSTOR name, as a replica's +// spec, a snapshot's node list, a storage pool, the wire or an operator +// spell it, address the same object: whether they map to one CR name, the +// way the store addresses the object. That covers Node-1 against node-1 and +// a CR name against the spelling it was registered under. Two case variants +// of a name outside RFC 1123, such as My_Res and my_res, hash to two CR +// names and are two objects, so they are not the same name here either. +func SameName(a, b string) bool { + return Name(a) == Name(b) +} + // 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 { diff --git a/pkg/store/k8s/storage_pools.go b/pkg/store/k8s/storage_pools.go index 1a02de16..d91ac523 100644 --- a/pkg/store/k8s/storage_pools.go +++ b/pkg/store/k8s/storage_pools.go @@ -75,6 +75,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 && SameName(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 +466,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 +487,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) } } From 8674fdbac36fb78ad4068fd9e3b8cedcbd9b7c3a Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 23:48:25 +0100 Subject: [PATCH 12/51] fix(controller): reach replicas, nodes and snapshot entries in any spelling The evacuation, LOST and migration paths built a replica's CR name as definition.node without folding it, so a replica of Pvc-Mix on Node-1 was not found: LOST never deleted the source and EVICTED never completed. They now look the replica up under the folded name, and by its spec when the name is outside RFC 1123. The orphan check and the state projection read the definition or the node by the spelling a replica written before CR names were stored may carry; both fold it. The snapshot phase gates and the adopted backfill matched a satellite's entry, written under its own node name, against the snapshot's node list, which carries the registered spelling; they compare the two as one node. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/controller/node_controller.go | 10 +- .../controller/node_spelling_lookups_test.go | 210 ++++++++++++++++++ internal/controller/replica_of.go | 39 +++- internal/controller/resource_controller.go | 3 +- .../resource_migration_controller.go | 10 +- .../resource_state_projection_controller.go | 5 +- internal/controller/snapshot_controller.go | 10 +- 7 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 internal/controller/node_spelling_lookups_test.go diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index bf4ef585..57e361db 100644 --- a/internal/controller/node_controller.go +++ b/internal/controller/node_controller.go @@ -322,7 +322,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) } @@ -412,7 +412,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 @@ -446,12 +446,6 @@ func (r *NodeReconciler) drainingNodes(ctx context.Context) (map[string]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_spelling_lookups_test.go b/internal/controller/node_spelling_lookups_test.go new file mode 100644 index 00000000..4fa9345b --- /dev/null +++ b/internal/controller/node_spelling_lookups_test.go @@ -0,0 +1,210 @@ +// 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/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", k8s.Name("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: k8s.Name(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: k8s.Name("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] + + if err := (&ResourceMigrationReconciler{Client: cli}).pruneSrc(ctx, res.Spec.ResourceDefinitionName, 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) + } +} diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go index da1f50e7..42ad251e 100644 --- a/internal/controller/replica_of.go +++ b/internal/controller/replica_of.go @@ -19,7 +19,11 @@ limitations under the License. package controller import ( - "strings" + "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/store/k8s" @@ -48,5 +52,36 @@ func sameDefinition(a, b string) bool { // spelling too. Both sides map to the node's CR name, which is how the // store addresses it. func sameNode(a, b string) bool { - return strings.EqualFold(a, b) || k8s.Name(a) == k8s.Name(b) + return k8s.SameName(a, b) +} + +// 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: k8s.Name(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 sameDefinition(item.Spec.ResourceDefinitionName, rd) && sameNode(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 5e0a9095..d3c10799 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -41,6 +41,7 @@ import ( "github.com/cozystack/blockstor/pkg/drbd" "github.com/cozystack/blockstor/pkg/effectiveprops" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // resourceFinalizer is the legacy controller-side finalizer the @@ -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: k8s.Name(target.Spec.ResourceDefinitionName)}, &rd) if err == nil { return false, nil } diff --git a/internal/controller/resource_migration_controller.go b/internal/controller/resource_migration_controller.go index 283bd9b3..bcf6bc49 100644 --- a/internal/controller/resource_migration_controller.go +++ b/internal/controller/resource_migration_controller.go @@ -122,9 +122,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.Spec.ResourceDefinitionName, srcNode) if err != nil { return ctrl.Result{}, err } @@ -135,7 +133,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 +142,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, rdName, srcNode string) error { var src blockstoriov1alpha1.Resource - err := r.Get(ctx, client.ObjectKey{Name: srcName}, &src) + err := getReplica(ctx, r.Client, rdName, srcNode, &src) if err != nil { if errors.IsNotFound(err) { return nil diff --git a/internal/controller/resource_state_projection_controller.go b/internal/controller/resource_state_projection_controller.go index bbb26b1d..ce692f95 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/store/k8s" ) // 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: k8s.Name(res.Spec.NodeName)}, &node) if err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil diff --git a/internal/controller/snapshot_controller.go b/internal/controller/snapshot_controller.go index 265db21b..2839b2aa 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/store/k8s" ) // 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 sameNode(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[k8s.Name(entries[i].NodeName)] = entries[i] } for _, t := range targets { - entry, ok := byNode[t] + entry, ok := byNode[k8s.Name(t)] if !ok { return false } From 88f0c94c7c42cc0e65e803a41e5a2196425926b6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 23:48:25 +0100 Subject: [PATCH 13/51] fix(satellite): take snapshots and replicas that spell their node otherwise A snapshot lists its nodes in the registered spelling, Node-1, and the satellite started as node-1 compared that list with its own name exactly, so it never took the snapshot. The event filter, the reconcile, the per-node entries and the peer fetch now compare the two as one node, and the fetch reads the peer's Node by its folded name. A replica written before CR names were stored may carry Pvc-Mix and Node-1 in spec. The definition Get, the RD and sibling fan-out, the peer list, the authoritative flags and the node-id check now read such a row as the same definition and node as a new one. Names are compared by the CR name they map to, so two case variants of a name outside RFC 1123, My_Res and my_res, stay two definitions. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/satellite/controllers/node_name.go | 14 +- .../controllers/old_row_spelling_test.go | 258 ++++++++++++++++++ pkg/satellite/controllers/resource.go | 17 +- pkg/satellite/controllers/snapshot.go | 12 +- pkg/satellite/controllers/snapshot_fetcher.go | 4 +- .../snapshot_node_spelling_test.go | 83 ++++++ pkg/satellite/controllers/snapshot_test.go | 42 +++ 7 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 pkg/satellite/controllers/old_row_spelling_test.go create mode 100644 pkg/satellite/controllers/snapshot_node_spelling_test.go diff --git a/pkg/satellite/controllers/node_name.go b/pkg/satellite/controllers/node_name.go index b2a00d43..02153bf5 100644 --- a/pkg/satellite/controllers/node_name.go +++ b/pkg/satellite/controllers/node_name.go @@ -18,7 +18,11 @@ limitations under the License. package controllers -import "github.com/cozystack/blockstor/pkg/store/k8s" +import ( + "slices" + + "github.com/cozystack/blockstor/pkg/store/k8s" +) // isNode reports whether name, as a replica or a pool spells its node, is // node. The store addresses a Node under its folded name, so a Node @@ -27,5 +31,11 @@ import "github.com/cozystack/blockstor/pkg/store/k8s" // replica written or adopted before the store and linstor-migrate stored // the node's CR name may carry Node-1: either is this satellite's. func isNode(name, node string) bool { - return k8s.Name(name) == k8s.Name(node) + return k8s.SameName(name, node) +} + +// listsNode reports whether a snapshot's node list, which carries the +// registered spellings, names node. +func listsNode(nodes []string, node string) bool { + return slices.ContainsFunc(nodes, func(n string) bool { return isNode(n, node) }) } 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/resource.go b/pkg/satellite/controllers/resource.go index 3ca3eb29..6470487f 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -51,6 +51,7 @@ import ( "github.com/cozystack/blockstor/pkg/effectiveprops" "github.com/cozystack/blockstor/pkg/luks" intent "github.com/cozystack/blockstor/pkg/satellite/intent" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // kubeInternalNICName is the NetInterface name the dispatcher prefers @@ -420,7 +421,7 @@ func (r *ResourceReconciler) enqueueResourcesForRD(ctx context.Context, obj clie for i := range localList.Items { local := &localList.Items[i] - if local.Spec.ResourceDefinitionName == rd.Name && + if k8s.SameName(local.Spec.ResourceDefinitionName, rd.Name) && isNode(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: k8s.Name(res.Spec.ResourceDefinitionName)}, &rd) if err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil @@ -1067,7 +1068,7 @@ 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 && + if k8s.SameName(local.Spec.ResourceDefinitionName, res.Spec.ResourceDefinitionName) && isNode(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 k8s.SameName(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 k8s.SameName(peers[i].Spec.NodeName, target.Spec.NodeName) && + k8s.SameName(peers[i].Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { target.Spec.Flags = peers[i].Spec.Flags return @@ -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 k8s.SameName(peer.Spec.NodeName, target.Spec.NodeName) { continue } @@ -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: k8s.Name(res.Spec.ResourceDefinitionName)}, &rd) if err == nil { out := make([]int32, 0, len(rd.Spec.VolumeDefinitions)) for i := range rd.Spec.VolumeDefinitions { diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index 910bba87..562b5028 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -87,7 +87,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 !listsNode(snap.Spec.Nodes, r.Config.NodeName) { return ctrl.Result{}, nil } @@ -358,7 +358,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 !isNode(entries[i].NodeName, nodeName) { continue } @@ -528,7 +528,7 @@ func upsertPerNodeStatusInPlace( mutate func(*blockstoriov1alpha1.SnapshotPerNodeStatus) bool, ) bool { for i := range *entries { - if (*entries)[i].NodeName != nodeName { + if !isNode((*entries)[i].NodeName, nodeName) { continue } @@ -625,7 +625,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 !isNode(entries[i].NodeName, nodeName) { continue } @@ -648,7 +648,7 @@ func upsertPerNodeStatus(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, no replaced := false for i := range entries { - if entries[i].NodeName == nodeName { + if isNode(entries[i].NodeName, nodeName) { merged := entries[i] merged.NodeName = nodeName merged.Ready = true @@ -737,7 +737,7 @@ func snapshotNodePredicate(nodeName string) predicate.Predicate { return false } - return slices.Contains(snap.Spec.Nodes, nodeName) + return listsNode(snap.Spec.Nodes, nodeName) } return predicate.Funcs{ diff --git a/pkg/satellite/controllers/snapshot_fetcher.go b/pkg/satellite/controllers/snapshot_fetcher.go index c5969a99..30eb55c1 100644 --- a/pkg/satellite/controllers/snapshot_fetcher.go +++ b/pkg/satellite/controllers/snapshot_fetcher.go @@ -89,7 +89,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 isNode(peer, f.NodeName) { continue } @@ -126,7 +126,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: k8s.Name(peerNode)}, &node) if err != nil { return "", errors.Wrapf(err, "get node CRD %s", peerNode) } 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..b0e46548 --- /dev/null +++ b/pkg/satellite/controllers/snapshot_node_spelling_test.go @@ -0,0 +1,83 @@ +// 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" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + + blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" +) + +// 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 := listsNode(snap.Spec.Nodes, "node-1"); got != want { + t.Errorf("listsNode([%s], 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") + } +} 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) + } +} From 676696ebeef0a60d46060dced16446fffc1cc90e Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 23:48:25 +0100 Subject: [PATCH 14/51] fix(rest,cli,placer): read two spellings of a node as one node The wire shows a replica's node as it was registered, Node-1, while an operator, a URL, a pool, a satellite or a snapshot's node list may spell it otherwise. Paths that picked a node's own replica out of a listing, or keyed a set by node, compared the spellings exactly: - the placer took a pool on node-1 for a free node next to a replica on Node-1 and placed a second replica there, and read such a pool past the node being EVICTED or LOST, past a node list, and past the node's Aux props, so a restore pinned to the snapshot's nodes placed nothing; - advise and query-size-info counted the pool of an EVICTED node; - a snapshot reported by the satellite as node-1 never counted as successful for Node-1; - the pool reference check missed replicas on the node, both guards of a multi-node evacuate passed, and peer delete, peer bump, takeover and pool inference took the node's own replica for a sibling; - the restore node check and the non-snapshot pool check missed the nodes a snapshot lists. All of them now compare the CR name a node maps to, the way the store addresses it. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/cli/node_spelling_pool_test.go | 55 ++++ internal/cli/snapshot.go | 3 +- internal/cli/write_more.go | 3 +- pkg/placer/placer.go | 60 +++-- pkg/placer/placer_test.go | 170 ++++++++++++ pkg/rest/advise.go | 3 +- pkg/rest/autoplace.go | 7 +- pkg/rest/node_lifecycle.go | 21 +- pkg/rest/node_lifecycle_test.go | 30 +++ pkg/rest/node_spelling_wire_test.go | 338 ++++++++++++++++++++++++ pkg/rest/peer_delete_sync.go | 3 +- pkg/rest/physical_storage.go | 3 +- pkg/rest/query_size_info.go | 8 +- pkg/rest/snapshot_restore.go | 4 +- pkg/rest/snapshots.go | 15 +- pkg/rest/storage_pools.go | 3 +- pkg/validate/snapshot.go | 13 +- pkg/validate/validate_test.go | 15 ++ 18 files changed, 700 insertions(+), 54 deletions(-) create mode 100644 internal/cli/node_spelling_pool_test.go create mode 100644 pkg/rest/node_spelling_wire_test.go 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/snapshot.go b/internal/cli/snapshot.go index 64593c0b..53a71f08 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/store/k8s" ) // errRollbackUnsupported explains a deliberate omission. @@ -1317,7 +1318,7 @@ func sourcePoolOn(ctx context.Context, run *runContext, srcRD, node string) (str continue } - if replicas[i].NodeName == node { + if k8s.SameName(replicas[i].NodeName, node) { return pool, nil } diff --git a/internal/cli/write_more.go b/internal/cli/write_more.go index b9f48f67..defa80bf 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/store/k8s" ) // kibPerMib is the binary step between adjacent size suffixes. The @@ -764,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 k8s.SameName(siblings[i].NodeName, node) || slices.Contains(siblings[i].Flags, apiv1.ResourceFlagDiskless) { continue } diff --git a/pkg/placer/placer.go b/pkg/placer/placer.go index 8e117509..9d28c808 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -36,6 +36,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // CapacityShortfallError reports that no candidate pool has enough @@ -402,7 +403,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[nodeKey(existing[i].NodeName)]; off { continue } @@ -442,6 +443,15 @@ func (p *Placer) placeDiskful(ctx context.Context, rdName string, state *state, return placed, nil } +// nodeKey is the key a node takes in the placer's node sets and maps. +// Replicas, pools and nodes all come from the wire, and a replica shows its +// node as it was registered, Node-1, while a pool keeps the spelling it was +// created with, node-1. Both map to the node's CR name, which is how the +// store addresses the node, so they are one key. +func nodeKey(name string) string { + return k8s.Name(name) +} + // placeDisklessOnRemaining ensures every healthy node not already // hosting a replica gets a DISKLESS one. Upstream LINSTOR uses this // for "cluster-wide attachable" volumes — any consumer Pod can mount @@ -452,7 +462,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[nodeKey(nodeName)]; busy { continue } @@ -469,7 +479,7 @@ func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, st return errors.Wrap(err, "create diskless witness") } - state.taken[nodeName] = struct{}{} + state.taken[nodeKey(nodeName)] = struct{}{} } return nil @@ -549,12 +559,12 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // 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] + s.upgradeable[nodeKey(existing[i].NodeName)] = existing[i].Props[storPoolNameProp] continue } - s.taken[existing[i].NodeName] = struct{}{} + s.taken[nodeKey(existing[i].NodeName)] = struct{}{} // Seed XReplicasOnDifferentMap buckets from this diskful // replica. DISKLESS witnesses (handled by the continue above) @@ -562,7 +572,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[nodeKey(existing[i].NodeName)] for key := range filter.XReplicasOnDifferentMap { s.xBuckets[xBucketKey(key, nodeProps)]++ } @@ -606,11 +616,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[nodeKey(pool.NodeName)]; busy { return nil, false } - nodeProps := s.nodes[pool.NodeName] + nodeProps := s.nodes[nodeKey(pool.NodeName)] if !matchesTuple(nodeProps, s.filter.ReplicasOnSame, s.sameTuple) { return nil, false @@ -716,7 +726,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[nodeKey(pool.NodeName)]; isWitness { err := s.promoteWitness(ctx, st, rdName, pool) if err != nil { return false, err @@ -734,8 +744,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[nodeKey(pool.NodeName)] = struct{}{} + delete(s.upgradeable, nodeKey(pool.NodeName)) if s.sameTuple == nil && len(s.filter.ReplicasOnSame) > 0 { s.sameTuple = lookupKeys(nodeProps, s.filter.ReplicasOnSame) @@ -879,13 +889,13 @@ func (p *Placer) applyNotPlaceWith(ctx context.Context, rdName string, filter *a } if slices.Contains(filter.NotPlaceWithRsc, all[i].Name) { - excluded[all[i].NodeName] = struct{}{} + excluded[nodeKey(all[i].NodeName)] = struct{}{} continue } if pattern != nil && pattern.MatchString(all[i].Name) { - excluded[all[i].NodeName] = struct{}{} + excluded[nodeKey(all[i].NodeName)] = struct{}{} } } @@ -896,7 +906,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[nodeKey(candidates[i].NodeName)]; blocked { continue } @@ -1267,7 +1277,7 @@ func matchesPoolFilter(pool *apiv1.StoragePool, filter *apiv1.AutoSelectFilter, return false } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[nodeKey(pool.NodeName)]; off { return false } @@ -1288,7 +1298,7 @@ func matchesPoolFilter(pool *apiv1.StoragePool, filter *apiv1.AutoSelectFilter, 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 nodeKey(n) == nodeKey(pool.NodeName) }) { return false } @@ -1343,7 +1353,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[nodeKey(nodes[i].Name)] = struct{}{} break } @@ -1386,7 +1396,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[nodeKey(nodes[i].Name)] = struct{}{} } } @@ -1401,7 +1411,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[nodeKey(list[i].Name)] = list[i].Props } return out, nil @@ -1414,7 +1424,7 @@ func topologyTuple(existing []apiv1.Resource, nodes map[string]map[string]string return nil } - return lookupKeys(nodes[existing[0].NodeName], keys) + return lookupKeys(nodes[nodeKey(existing[0].NodeName)], keys) } func topologySeen(existing []apiv1.Resource, nodes map[string]map[string]string, keys []string) map[string]struct{} { @@ -1431,7 +1441,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[nodeKey(existing[i].NodeName)][auxKey(k)] out[k+"="+value] = struct{}{} } } @@ -1569,7 +1579,7 @@ func partitionByExclusion(candidates []apiv1.StoragePool, nodes map[string]map[s for i := range candidates { pool := candidates[i] - nodeProps := nodes[pool.NodeName] + nodeProps := nodes[nodeKey(pool.NodeName)] if matchesAnyExcludedPair(nodeProps, keys) { lastResort = append(lastResort, pool) @@ -1626,7 +1636,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[nodeKey(candidates[i].NodeName)] if matchesAllSamePins(nodeProps, keys) { out = append(out, candidates[i]) @@ -1679,7 +1689,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[nodeKey(pool.NodeName)], keys) key := tupleKey(tuple) grp, ok := byKey[key] @@ -1689,7 +1699,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[nodeKey(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..841bf8c5 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/store/k8s" ) // registerAdvise wires `linstor advise resource` and the cluster-wide @@ -146,7 +147,7 @@ func pickAdvicePools(pools []apiv1.StoragePool, filter *apiv1.AutoSelectFilter, continue } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[k8s.Name(pool.NodeName)]; off { continue } diff --git a/pkg/rest/autoplace.go b/pkg/rest/autoplace.go index 36b4a699..71fd8021 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -36,6 +36,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/placer" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // AutoTiebreakerSuppressedUntilAnnotation is re-exported from @@ -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 !k8s.SameName(env.Resource.NodeName, node): writeError(w, http.StatusBadRequest, "single-node resource create: body node '"+env.Resource.NodeName+ "' does not match URL path node '"+node+"'") @@ -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 k8s.SameName(siblings[i].NodeName, takeoverNode) { continue } @@ -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 k8s.SameName(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/node_lifecycle.go b/pkg/rest/node_lifecycle.go index dc79160a..9c603a5f 100644 --- a/pkg/rest/node_lifecycle.go +++ b/pkg/rest/node_lifecycle.go @@ -31,6 +31,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // registerNodeLifecycle wires the eviction / restore / lost endpoints. @@ -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 !k8s.SameName(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[k8s.Name(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[k8s.Name(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[k8s.Name(res.NodeName)] { continue } @@ -656,3 +659,11 @@ func removeFlag(flag string) func([]string) []string { return out } } + +// nodeKey is the key a node takes in a set built from wire rows. A replica +// shows its node as it was registered, Node-1, while a satellite reports +// itself and a pool keeps the spelling it was created with, node-1: LINSTOR +// names are case-insensitive, so the two are one node. +func nodeKey(name string) string { + return strings.ToLower(name) +} 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..1245c412 --- /dev/null +++ b/pkg/rest/node_spelling_wire_test.go @@ -0,0 +1,338 @@ +// 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/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[nodeKey("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) + } +} diff --git a/pkg/rest/peer_delete_sync.go b/pkg/rest/peer_delete_sync.go index 47b13ec9..7d84d8b0 100644 --- a/pkg/rest/peer_delete_sync.go +++ b/pkg/rest/peer_delete_sync.go @@ -25,6 +25,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // peerDeleteAckTimeout bounds how long handleResourceDelete waits for @@ -105,7 +106,7 @@ func (s *Server) waitForPeerDeletionAcks(ctx context.Context, rdName, removedNod for i := range siblings { sib := &siblings[i] - if sib.NodeName == removedNode { + if k8s.SameName(sib.NodeName, removedNode) { continue } diff --git a/pkg/rest/physical_storage.go b/pkg/rest/physical_storage.go index 3bf32486..daaa44af 100644 --- a/pkg/rest/physical_storage.go +++ b/pkg/rest/physical_storage.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/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // Canonical upstream-LINSTOR `DeviceProviderKind` enum values. Mirrors @@ -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 k8s.SameName(p.Spec.NodeName, nodeName) && p.Spec.PoolName == poolName { return p } } diff --git a/pkg/rest/query_size_info.go b/pkg/rest/query_size_info.go index 49e90f2f..37dbd605 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/store/k8s" ) // registerQuerySizeInfo wires `linstor query-size-info` and the @@ -179,7 +180,7 @@ func collectCandidates(pools []apiv1.StoragePool, disabled map[string]struct{}, continue } - if _, off := disabled[pool.NodeName]; off { + if _, off := disabled[k8s.Name(pool.NodeName)]; off { 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[k8s.Name(nodes[i].Name)] = struct{}{} break } diff --git a/pkg/rest/snapshot_restore.go b/pkg/rest/snapshot_restore.go index 63436d44..83f5b691 100644 --- a/pkg/rest/snapshot_restore.go +++ b/pkg/rest/snapshot_restore.go @@ -1784,7 +1784,7 @@ func (s *Server) stampRestoredResourcesOnNodes(ctx context.Context, srcRDName, n NodeName: node, } - pool := poolByNode[node] + pool := poolByNode[nodeKey(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[nodeKey(resList[i].NodeName)] = pool if fallback == "" { fallback = pool diff --git a/pkg/rest/snapshots.go b/pkg/rest/snapshots.go index 55672bf6..feb521e6 100644 --- a/pkg/rest/snapshots.go +++ b/pkg/rest/snapshots.go @@ -29,6 +29,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // registerSnapshots wires snapshot endpoints. Three different paths land @@ -446,7 +447,7 @@ func isSnapshotSuccessful(snap *apiv1.Snapshot, diskful map[string]struct{}) boo continue } - reported[entry.NodeName] = struct{}{} + reported[nodeKey(entry.NodeName)] = struct{}{} } for node := range denominator { @@ -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[nodeKey(node)]; ok { + out[nodeKey(node)] = struct{}{} } } @@ -516,7 +517,7 @@ func (s *Server) diskfulPeerSet(ctx context.Context, rdName string) (map[string] continue } - out[resList[i].NodeName] = struct{}{} + out[nodeKey(resList[i].NodeName)] = struct{}{} } return out, nil @@ -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[k8s.Name(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[k8s.Name(res.NodeName)]; !ok { continue } } diff --git a/pkg/rest/storage_pools.go b/pkg/rest/storage_pools.go index eb1eeb21..ef53a780 100644 --- a/pkg/rest/storage_pools.go +++ b/pkg/rest/storage_pools.go @@ -29,6 +29,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/store" + "github.com/cozystack/blockstor/pkg/store/k8s" "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 !k8s.SameName(all[i].NodeName, node) { continue } diff --git a/pkg/validate/snapshot.go b/pkg/validate/snapshot.go index d212aa9a..d8ea9b22 100644 --- a/pkg/validate/snapshot.go +++ b/pkg/validate/snapshot.go @@ -22,6 +22,8 @@ import ( "errors" "fmt" "strings" + + "github.com/cozystack/blockstor/pkg/store/k8s" ) // 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[k8s.Name(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 := k8s.Name(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) + } +} From a8912ce5eb5ed5a6d2da88b6a8f45f77accd0576 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 2 Nov 2026 23:48:25 +0100 Subject: [PATCH 15/51] docs(site): name the snapshot path after the definition's CR name Assisted-by: LLM Signed-off-by: Andrei Kvapil --- site/content/en/docs/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/content/en/docs/migration.md b/site/content/en/docs/migration.md index 722b7078..52e34320 100644 --- a/site/content/en/docs/migration.md +++ b/site/content/en/docs/migration.md @@ -62,7 +62,7 @@ Not converted, and listed in the tool's report: 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. From a9099db9b551f46a35b23759adaf5ca40f705827 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 3 Nov 2026 02:12:07 +0100 Subject: [PATCH 16/51] fix(satellite): render the paths of a connection named in another spelling A connection with paths names its nodes as the request spelled them, Node-1, while the hosts of the .res file carry the replicas' spec, which now stores the node's CR name, node-1. The renderer matched the two exactly and dropped the paths without an error. Each node of a connection is now written in the spelling of the host it names. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/satellite/connection_spelling_test.go | 63 +++++++++++++++++++++++ pkg/satellite/reconciler.go | 26 ++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 pkg/satellite/connection_spelling_test.go 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/reconciler.go b/pkg/satellite/reconciler.go index 83d2d09d..8e05f73b 100644 --- a/pkg/satellite/reconciler.go +++ b/pkg/satellite/reconciler.go @@ -39,6 +39,7 @@ import ( "github.com/cozystack/blockstor/pkg/luks" intent "github.com/cozystack/blockstor/pkg/satellite/intent" "github.com/cozystack/blockstor/pkg/storage" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // ReconcilerConfig parametrises a Reconciler. @@ -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 k8s.SameName(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. From d5bae2618bdb22a813801b1c114392e22925f043 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 3 Nov 2026 02:12:07 +0100 Subject: [PATCH 17/51] fix(store): list a node's devices under its CR name The satellite labels its devices with its node's CR name, node-1, and the listing for a node asked under the spelling it was given, so `ps cdp Node-1` found none. It now asks under the CR name, and under the given spelling too when that is another valid label value. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../k8s/physical_device_node_spelling_test.go | 57 +++++++++++++++++++ pkg/store/k8s/physicaldevices.go | 32 ++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 pkg/store/k8s/physical_device_node_spelling_test.go 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 }) From 7e13c2a6226fa714d63ec8b7daa442c23d32ce7a Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 16:52:10 +0100 Subject: [PATCH 18/51] fix(store): fail a listing or write whose parent cannot be read The parent lookup that supplies a child's registered spelling dropped every Get error. A listing then came back short of the rows stored under that spelling, and a definition delete read the short answer as "no snapshots" and left them behind. Only NotFound now means the parent is not there; any other error fails the call. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/k8s/resources.go | 27 ++++- pkg/store/k8s/snapshots.go | 12 ++- pkg/store/k8s/spelling_lookup_test.go | 143 ++++++++++++++++++++++++++ pkg/store/k8s/spellings.go | 66 +++++++----- 4 files changed, 217 insertions(+), 31 deletions(-) create mode 100644 pkg/store/k8s/spelling_lookup_test.go diff --git a/pkg/store/k8s/resources.go b/pkg/store/k8s/resources.go index 8c88d9b4..fed550ca 100644 --- a/pkg/store/k8s/resources.go +++ b/pkg/store/k8s/resources.go @@ -89,7 +89,12 @@ func (s *resources) List(ctx context.Context) ([]apiv1.Resource, error) { func (s *resources) ListByNode(ctx context.Context, node string) ([]apiv1.Resource, error) { reader := s.nodeScopedReader() - out, err := s.listUnderSpellings(ctx, reader, FieldResourceNodeName, nodeSpellings(ctx, reader, node), + 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 @@ -183,8 +188,15 @@ func (s *resources) Create(ctx context.Context, in *apiv1.Resource) error { // 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 := storedRDName(ctx, s.c, in.Name) - node := storedNodeName(ctx, s.c, in.NodeName) + rd, err := storedRDName(ctx, s.c, in.Name) + if err != nil { + return err + } + + node, err := storedNodeName(ctx, s.c, in.NodeName) + if err != nil { + return err + } shown := *in shown.Name = rd.display @@ -198,7 +210,7 @@ func (s *resources) Create(ctx context.Context, in *apiv1.Resource) error { rd.annotate(&crd.ObjectMeta, AnnotationLinstorRDName) node.annotate(&crd.ObjectMeta, AnnotationLinstorNodeName) - err := s.c.Create(ctx, crd) + 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) @@ -572,7 +584,12 @@ 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.listUnderSpellings(ctx, reader, FieldResourceDefinitionName, rdSpellings(ctx, reader, 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 diff --git a/pkg/store/k8s/snapshots.go b/pkg/store/k8s/snapshots.go index 088a367f..13d2d41a 100644 --- a/pkg/store/k8s/snapshots.go +++ b/pkg/store/k8s/snapshots.go @@ -150,7 +150,10 @@ func (s *snapshots) Create(ctx context.Context, in *apiv1.Snapshot) error { // The spec stores the definition's CR name and the registered spelling // is kept for display, as for a replica; see spellings.go. - rd := storedRDName(ctx, s.c, in.ResourceName) + rd, err := storedRDName(ctx, s.c, in.ResourceName) + if err != nil { + return err + } shown := *in shown.ResourceName = rd.display @@ -160,7 +163,7 @@ func (s *snapshots) Create(ctx context.Context, in *apiv1.Snapshot) error { crd.Labels[LabelResourceDefinition] = rd.crName rd.annotate(&crd.ObjectMeta, AnnotationLinstorRDName) - err := s.c.Create(ctx, crd) + 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) @@ -308,7 +311,10 @@ func (s *snapshots) Delete(ctx context.Context, rdName, snapName string) error { func (s *snapshots) listByDefinitionThrough( ctx context.Context, reader ctrlclient.Reader, rdName string, ) ([]apiv1.Snapshot, error) { - spellings := rdSpellings(ctx, reader, rdName) + spellings, err := rdSpellings(ctx, reader, rdName) + if err != nil { + return nil, err + } var kept []crdv1alpha1.Snapshot diff --git a/pkg/store/k8s/spelling_lookup_test.go b/pkg/store/k8s/spelling_lookup_test.go new file mode 100644 index 00000000..509678d3 --- /dev/null +++ b/pkg/store/k8s/spelling_lookup_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 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) + } +} diff --git a/pkg/store/k8s/spellings.go b/pkg/store/k8s/spellings.go index 2cdd18fd..6b9bba2f 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -23,6 +23,8 @@ import ( "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" @@ -51,6 +53,10 @@ import ( // spelling in spec, so a scoped listing asks under every spelling the name // can reasonably have been stored in: the one given, the folded one, and the // registered 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 @@ -97,23 +103,25 @@ func (n storedName) annotate(meta *metav1.ObjectMeta, key string) { // 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 { - if found, ok := lookupRD(ctx, reader, rdName); ok { - return found +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} + 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 { - if found, ok := lookupNode(ctx, reader, node); ok { - return found +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} + return storedName{crName: Name(node), display: node}, nil } // shownName is the spelling to show for a parent a child stores as stored: @@ -126,41 +134,53 @@ func shownName(meta *metav1.ObjectMeta, key, stored string) string { return stored } -func lookupRD(ctx context.Context, reader ctrlclient.Reader, rdName string) (storedName, bool) { +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 { - return storedName{}, false + 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 + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true, nil } -func lookupNode(ctx context.Context, reader ctrlclient.Reader, node string) (storedName, bool) { +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 { - return storedName{}, false + 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 + return storedName{crName: crd.Name, display: OriginalName(&crd.ObjectMeta)}, true, nil } -// rdSpellings and nodeSpellings answer the spellings to ask under. A failed -// lookup of the registered object only drops its spellings: the listing -// still asks under the given and the folded ones, which is what it did before. -func rdSpellings(ctx context.Context, reader ctrlclient.Reader, rdName string) []string { - found, ok := lookupRD(ctx, reader, rdName) +// 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) + return spellingSet(rdName, found, ok), nil } -func nodeSpellings(ctx context.Context, reader ctrlclient.Reader, node string) []string { - found, ok := lookupNode(ctx, reader, node) +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) + return spellingSet(node, found, ok), nil } func spellingSet(given string, found storedName, haveFound bool) []string { From cc29f7816e237918ba1499b0b3eb08532783d444 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 16:52:25 +0100 Subject: [PATCH 19/51] fix(store): list a definition's children under no other definition's spelling A listing also asked under the given name in lowercase. For a name outside RFC 1123 that is a different definition, hashed to another CR name, so listing My_Res returned my_res's replicas and snapshots. For any other name the CR name already is the lowercase spelling. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/k8s/spelling_lookup_test.go | 37 +++++++++++++++++++++++++++ pkg/store/k8s/spellings.go | 8 +++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/pkg/store/k8s/spelling_lookup_test.go b/pkg/store/k8s/spelling_lookup_test.go index 509678d3..92edc610 100644 --- a/pkg/store/k8s/spelling_lookup_test.go +++ b/pkg/store/k8s/spelling_lookup_test.go @@ -141,3 +141,40 @@ func TestAWriteWhoseParentCannotBeReadFails(t *testing.T) { 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) + } +} diff --git a/pkg/store/k8s/spellings.go b/pkg/store/k8s/spellings.go index 6b9bba2f..26cb1c96 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -30,7 +30,6 @@ import ( ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" crdv1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store" ) // LINSTOR names are case-insensitive, and the store addresses one object @@ -51,8 +50,10 @@ import ( // // 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, the folded one, and the -// registered one. +// 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 @@ -192,7 +193,6 @@ func spellingSet(given string, found storedName, haveFound bool) []string { } } - add(store.FoldName(given)) add(Name(given)) if haveFound { From d2fee3c5e48d55efc20be4080670d9ddb8756a85 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 16:52:41 +0100 Subject: [PATCH 20/51] fix(store): derive the parent names a child shows, never take them from a caller The display-spelling annotations a replica or snapshot shows for its parents were taken from a caller's annotations on create, and on update when the object had none of its own. A caller could make the store show any definition or node name. They are now dropped from what a caller writes and derived only from the resolved parents. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/k8s/resources.go | 5 +- pkg/store/k8s/snapshots.go | 3 +- pkg/store/k8s/spelling_lookup_test.go | 70 +++++++++++++++++++++++++++ pkg/store/k8s/spellings.go | 22 +++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/pkg/store/k8s/resources.go b/pkg/store/k8s/resources.go index fed550ca..479f3eaf 100644 --- a/pkg/store/k8s/resources.go +++ b/pkg/store/k8s/resources.go @@ -203,6 +203,7 @@ func (s *resources) Create(ctx context.Context, in *apiv1.Resource) error { 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 @@ -285,7 +286,7 @@ 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, + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(in.Annotations), crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) err = s.c.Update(ctx, &existing) @@ -367,7 +368,7 @@ 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, + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(wire.Annotations), crdv1alpha1.ResourceAnnotationVolumeNumbers, AnnotationLinstorRDName, AnnotationLinstorNodeName) return s.c.Patch(ctx, &existing, ctrlclient.MergeFromWithOptions(base, ctrlclient.MergeFromWithOptimisticLock{})) diff --git a/pkg/store/k8s/snapshots.go b/pkg/store/k8s/snapshots.go index 13d2d41a..8311da15 100644 --- a/pkg/store/k8s/snapshots.go +++ b/pkg/store/k8s/snapshots.go @@ -159,6 +159,7 @@ func (s *snapshots) Create(ctx context.Context, in *apiv1.Snapshot) error { 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) @@ -234,7 +235,7 @@ func (s *snapshots) Update(ctx context.Context, in *apiv1.Snapshot) error { existing.Spec.GroupID = preservedGroupID existing.Spec.GroupSize = preservedGroupSize existing.Spec.ResourceDefinitionName = preservedRD - mergeUserAnnotationsInto(&existing.ObjectMeta, in.Annotations, AnnotationLinstorRDName) + mergeUserAnnotationsInto(&existing.ObjectMeta, withoutCallerNameAnnotations(in.Annotations), AnnotationLinstorRDName) return s.c.Update(ctx, &existing) }), "update Snapshot %s/%s", in.ResourceName, in.Name) diff --git a/pkg/store/k8s/spelling_lookup_test.go b/pkg/store/k8s/spelling_lookup_test.go index 92edc610..23c90896 100644 --- a/pkg/store/k8s/spelling_lookup_test.go +++ b/pkg/store/k8s/spelling_lookup_test.go @@ -178,3 +178,73 @@ func TestAListingAsksNoOtherDefinitionsSpelling(t *testing.T) { 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 index 26cb1c96..f4ae580f 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -203,6 +203,28 @@ func spellingSet(given string, found storedName, haveFound bool) []string { 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. From 97712314fbac13bf19b8c7b102c88eb00bb8f252 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 16:52:52 +0100 Subject: [PATCH 21/51] fix(rest): key nodes by their CR name in the snapshot and restore checks The snapshot success check and the restore pool map folded node names to lowercase. My_Node and my_node hash to two CR names and are two nodes, so a snapshot reported on one read as taken on both, and an entry reported under a node's CR name matched no registered node. They now key by the CR name, the way the store addresses a node. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/name_spelling_cr_test.go | 213 ++++++++++++++++++++++++++++++ pkg/rest/node_lifecycle.go | 12 +- 2 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 pkg/rest/name_spelling_cr_test.go diff --git a/pkg/rest/name_spelling_cr_test.go b/pkg/rest/name_spelling_cr_test.go new file mode 100644 index 00000000..058e38f0 --- /dev/null +++ b/pkg/rest/name_spelling_cr_test.go @@ -0,0 +1,213 @@ +// 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/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: k8s.Name("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{}{nodeKey("My_Node"): {}, nodeKey("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{}{nodeKey("My_Node"): {}} + snap := &apiv1.Snapshot{ + Nodes: []string{"My_Node"}, + Snapshots: []apiv1.SnapshotPerNode{{NodeName: k8s.Name("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[nodeKey("My_Node")]; got != "pool-upper" { + t.Errorf("pool for My_Node = %q, want pool-upper", got) + } + + if got := byNode[nodeKey("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) + } +} diff --git a/pkg/rest/node_lifecycle.go b/pkg/rest/node_lifecycle.go index 9c603a5f..208bf9f4 100644 --- a/pkg/rest/node_lifecycle.go +++ b/pkg/rest/node_lifecycle.go @@ -660,10 +660,12 @@ func removeFlag(flag string) func([]string) []string { } } -// nodeKey is the key a node takes in a set built from wire rows. A replica -// shows its node as it was registered, Node-1, while a satellite reports -// itself and a pool keeps the spelling it was created with, node-1: LINSTOR -// names are case-insensitive, so the two are one node. +// nodeKey is the key a node takes in a set built from wire rows: its CR name, +// the way the store addresses it. A replica shows its node as it was +// registered, Node-1, a satellite may report itself as node-1 and a status +// entry may carry the CR name itself; all three are one node. Two case +// variants of a name outside RFC 1123, My_Node and my_node, hash to two CR +// names and stay two nodes. func nodeKey(name string) string { - return strings.ToLower(name) + return k8s.Name(name) } From f4fdb86b342f3ba53b3314e168f948514d0dcd88 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 17:44:10 +0100 Subject: [PATCH 22/51] fix(controller): key node sets by CR name in the witness and auto-diskful paths A replica names its node by the CR name or by the spelling it was registered under, while the node list shows the registered one. The disabled and known sets and the witness exclusions compared the two exactly, so a witness on an EVICTED node registered as Node-1 was read as live and never moved, and auto-diskful counted a replica on an evicted node. The direct node probe folded case instead and read My_Node and my_node as one node. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../controller/auto_diskful_controller.go | 7 +- .../ensure_tiebreaker_evict_spelling_test.go | 100 ++++++++++ internal/controller/node_set_identity_test.go | 180 ++++++++++++++++++ .../resourcedefinition_controller.go | 42 ++-- 4 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 internal/controller/ensure_tiebreaker_evict_spelling_test.go create mode 100644 internal/controller/node_set_identity_test.go diff --git a/internal/controller/auto_diskful_controller.go b/internal/controller/auto_diskful_controller.go index da34e660..f45cc4d9 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.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/store" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // AutoDiskfulReconciler implements the `DrbdOptions/auto-diskful=` @@ -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[k8s.Name(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[k8s.Name(nodes[i].Name)] = struct{}{} } } 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/node_set_identity_test.go b/internal/controller/node_set_identity_test.go new file mode 100644 index 00000000..392eddb2 --- /dev/null +++ b/internal/controller/node_set_identity_test.go @@ -0,0 +1,180 @@ +// 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/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: k8s.Name("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, k8s.Name("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/resourcedefinition_controller.go b/internal/controller/resourcedefinition_controller.go index 5670b9f6..293317a8 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" @@ -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[k8s.Name(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[k8s.Name(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[k8s.Name(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[k8s.Name(nodes[i].Name)] = struct{}{} if isDisabledNode(&nodes[i]) { - disabled[nodes[i].Name] = struct{}{} + disabled[k8s.Name(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[k8s.Name(replicas[i].NodeName)]; ok { live = append(live, replicas[i]) continue @@ -1522,9 +1525,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) { @@ -1564,17 +1567,18 @@ func (r *ResourceDefinitionReconciler) probeNodeDirect(ctx context.Context, wire } // 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. +// given LINSTOR wire name, the way the store addresses it: by the CR +// name the wire name maps to, or by the preserved original-name +// annotation of a CRD named by hand. Folding case instead would read +// My_Node and my_node, two CRs, as one node. func nodeCRDMatchesWireName(node *blockstoriov1alpha1.Node, wireName string) bool { - if strings.EqualFold(node.Name, wireName) { + if node.Name == k8s.Name(wireName) { return true } original, ok := node.Annotations[linstorNameAnnotation] - return ok && strings.EqualFold(original, wireName) + return ok && k8s.SameName(original, wireName) } // splitByDisabledNode partitions replicas into (live, stranded) by the @@ -1585,7 +1589,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[k8s.Name(replicas[i].NodeName)]; off { stranded = append(stranded, replicas[i]) continue From 4036c0fe0c160691db8b02a30dbeb25cd911deb6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 17:45:33 +0100 Subject: [PATCH 23/51] fix(rest): skip replicas on evicted nodes in additional_place_count by CR name The disabled-node set is keyed by CR name, but the additional replica count looked a replica's node up as the wire spells it. A replica on an EVICTED node registered as Node-1 was counted as live, so +1 asked for one replica too many. The error-report node filter folded case and merged My_Node and my_node; it now compares CR names too. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/autoplace.go | 2 +- pkg/rest/error_reports.go | 4 +- pkg/rest/name_spelling_cr_test.go | 69 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/pkg/rest/autoplace.go b/pkg/rest/autoplace.go index 71fd8021..ef541279 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -644,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[nodeKey(existing[i].NodeName)]; off { continue } diff --git a/pkg/rest/error_reports.go b/pkg/rest/error_reports.go index 712852f1..b4a34737 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/store/k8s" ) // errorReportRingCap caps the in-memory buffer at 1000 entries — 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 k8s.SameName(entries[i].NodeName, node) { filtered = append(filtered, entries[i]) } } diff --git a/pkg/rest/name_spelling_cr_test.go b/pkg/rest/name_spelling_cr_test.go index 058e38f0..8529676d 100644 --- a/pkg/rest/name_spelling_cr_test.go +++ b/pkg/rest/name_spelling_cr_test.go @@ -35,6 +35,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/store/k8s" ) @@ -211,3 +212,71 @@ func TestAnHTTPCreateCannotForgeTheShownParentNames(t *testing.T) { 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) + } + } +} From 70e8f4ee739ce32013f66ad5044c41507fe959fa Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 17:54:59 +0100 Subject: [PATCH 24/51] fix(satellite): name a replica's resource by its definition's CR name A replica written before the store kept CR names in spec names its definition Pvc-Mix. Now that the satellite finds such a definition, it laid the resource down under the raw spec value while snapshot requests, and every replica written since, use the CR name pvc-mix: the snapshot failed with "resource not known on this satellite". The intent, teardown, snapshot phases, orphan sweepers, the derived port and minor, the day0 generation check and a restore's source now all use the CR name. A row that already stores it is unaffected. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/dispatcher/dispatcher.go | 29 +- pkg/dispatcher/old_spelling_name_test.go | 112 +++++++ .../old_spelling_name_internal_test.go | 306 ++++++++++++++++++ pkg/satellite/controllers/resource.go | 2 +- .../controllers/resource_toggle_disk.go | 3 +- pkg/satellite/controllers/snapshot.go | 11 +- pkg/satellite/controllers/storage_sweeper.go | 15 +- pkg/satellite/controllers/sweeper.go | 3 +- 8 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 pkg/dispatcher/old_spelling_name_test.go create mode 100644 pkg/satellite/controllers/old_spelling_name_internal_test.go diff --git a/pkg/dispatcher/dispatcher.go b/pkg/dispatcher/dispatcher.go index 9888df6f..32ae96f6 100644 --- a/pkg/dispatcher/dispatcher.go +++ b/pkg/dispatcher/dispatcher.go @@ -250,7 +250,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: k8s.Name(target.Spec.ResourceDefinitionName), NodeName: target.Spec.NodeName, Flags: target.Spec.Flags, Props: wireProps, @@ -424,7 +428,7 @@ func readDRBDPort(target *blockstoriov1alpha1.Resource, _ []blockstoriov1alpha1. return int(*target.Status.DRBDPort) } - return derivePort(target.Spec.ResourceDefinitionName) + return derivePort(k8s.Name(target.Spec.ResourceDefinitionName)) } // readDRBDMinor returns the BASE minor (volume 0) for the resource, @@ -449,7 +453,7 @@ func readDRBDMinor(target *blockstoriov1alpha1.Resource, rd *blockstoriov1alpha1 return int(*target.Status.DRBDMinor) } - return deriveMinor(target.Spec.ResourceDefinitionName) + return deriveMinor(k8s.Name(target.Spec.ResourceDefinitionName)) } // peerPortOf reads the persisted DRBDPort off a peer Resource. The @@ -669,7 +673,7 @@ func anyDiskfulPeerHasData(peers []blockstoriov1alpha1.Resource) bool { continue } - rdName := peers[i].Spec.ResourceDefinitionName + rdName := k8s.Name(peers[i].Spec.ResourceDefinitionName) for j := range peers[i].Status.Volumes { vol := &peers[i].Status.Volumes[j] @@ -882,7 +886,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 +928,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 k8s.Name(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. 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/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/resource.go b/pkg/satellite/controllers/resource.go index 6470487f..5d16b028 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -1476,7 +1476,7 @@ func (r *ResourceReconciler) handleDelete(ctx context.Context, res *blockstoriov } req := &intent.DeleteResourceRequest{ - Name: res.Spec.ResourceDefinitionName, + Name: k8s.Name(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } diff --git a/pkg/satellite/controllers/resource_toggle_disk.go b/pkg/satellite/controllers/resource_toggle_disk.go index 2ecc15a3..90fd94ff 100644 --- a/pkg/satellite/controllers/resource_toggle_disk.go +++ b/pkg/satellite/controllers/resource_toggle_disk.go @@ -34,6 +34,7 @@ import ( apiv1 "github.com/cozystack/blockstor/pkg/api/v1" "github.com/cozystack/blockstor/pkg/drbd" intent "github.com/cozystack/blockstor/pkg/satellite/intent" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // ToggleDiskRetryCap is the soft upper bound the reconciler surfaces @@ -212,7 +213,7 @@ func (r *ResourceReconciler) handleToggleDiskCancel(ctx context.Context, res *bl } req := &intent.DeleteResourceRequest{ - Name: res.Spec.ResourceDefinitionName, + Name: k8s.Name(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index 562b5028..745f1c43 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -34,6 +34,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" intent "github.com/cozystack/blockstor/pkg/satellite/intent" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // SatelliteSnapshotFinalizer guards a Snapshot CRD while one of @@ -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, k8s.Name(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, k8s.Name(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: k8s.Name(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, k8s.Name(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 @@ -697,7 +698,7 @@ func (r *SnapshotReconciler) handleDelete(ctx context.Context, snap *blockstorio } req := &intent.DeleteSnapshotRequest{ - ResourceName: snap.Spec.ResourceDefinitionName, + ResourceName: k8s.Name(snap.Spec.ResourceDefinitionName), SnapshotName: snap.Spec.SnapshotName, } diff --git a/pkg/satellite/controllers/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index 0fafaee6..6a492ef1 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -32,6 +32,7 @@ import ( blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" "github.com/cozystack/blockstor/pkg/storage" + "github.com/cozystack/blockstor/pkg/store/k8s" ) // StorageSweeperPeriod is the default cadence of the storage-orphan @@ -383,14 +384,18 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) 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 := k8s.Name(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{}{} } } diff --git a/pkg/satellite/controllers/sweeper.go b/pkg/satellite/controllers/sweeper.go index 02b2546e..51c0d199 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/store/k8s" ) // SweeperPeriod is the default cadence at which the orphan sweeper @@ -605,7 +606,7 @@ func (s *OrphanSweeperRunnable) listOwnedResourceNames(ctx context.Context) (map continue } - out[r.Spec.ResourceDefinitionName] = struct{}{} + out[k8s.Name(r.Spec.ResourceDefinitionName)] = struct{}{} } return out, nil From 256d5cfeb2b88a3f52771a900b0822532d6cf82b Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 18:23:45 +0100 Subject: [PATCH 25/51] fix(satellite): reach a replica's CR through the registered spellings The kernel and the apply chain know a resource by its definition's CR name, a hash for a name outside RFC 1123, while the store names the replica's Resource CR, and a Snapshot CR, from the spellings the definition and the node were registered under. The observer, the SkipDisk clearer, the cross-node snapshot fetcher and the MetadataCreated and FilesystemFormatted stampers built the CR name from the kernel name, so for such a definition status, KernelLoaded, SkipDisk and the conditions were never written. They now map back through the definition and the node, the way the store named the CR. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/satellite/controllers/cr_names.go | 87 ++++++ .../filesystem_formatted_stamper.go | 24 +- .../filesystem_formatted_stamper_test.go | 8 +- .../controllers/metadata_created_backfill.go | 2 +- .../controllers/metadata_created_stamper.go | 16 +- pkg/satellite/controllers/observer.go | 31 +- .../controllers/observer_cr_name_test.go | 265 ++++++++++++++++++ .../controllers/skip_disk_clearer.go | 8 +- pkg/satellite/controllers/snapshot_fetcher.go | 7 +- pkg/satellite/createmd_test.go | 4 +- pkg/satellite/reconciler.go | 28 +- pkg/satellite/reconciler_drbd_test.go | 8 +- .../reconciler_metadata_created_test.go | 4 +- pkg/store/k8s/spellings.go | 14 + 14 files changed, 459 insertions(+), 47 deletions(-) create mode 100644 pkg/satellite/controllers/cr_names.go create mode 100644 pkg/satellite/controllers/observer_cr_name_test.go diff --git a/pkg/satellite/controllers/cr_names.go b/pkg/satellite/controllers/cr_names.go new file mode 100644 index 00000000..dde6f466 --- /dev/null +++ b/pkg/satellite/controllers/cr_names.go @@ -0,0 +1,87 @@ +// 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/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: k8s.Name(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: k8s.Name(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/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/metadata_created_backfill.go b/pkg/satellite/controllers/metadata_created_backfill.go index 71263f42..b9c1f600 100644 --- a/pkg/satellite/controllers/metadata_created_backfill.go +++ b/pkg/satellite/controllers/metadata_created_backfill.go @@ -163,7 +163,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/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..0fbafc68 --- /dev/null +++ b/pkg/satellite/controllers/observer_cr_name_test.go @@ -0,0 +1,265 @@ +// 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/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", k8s.Name("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: k8s.Name(tc.registered)}} + k8s.SetOriginalName(&rd.ObjectMeta, tc.registered) + + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(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 := k8s.Name(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: k8s.Name("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: k8s.Name("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: k8s.Name("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: k8s.Name("My_Res")}} + k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") + + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("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/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_fetcher.go b/pkg/satellite/controllers/snapshot_fetcher.go index 30eb55c1..bce19fa9 100644 --- a/pkg/satellite/controllers/snapshot_fetcher.go +++ b/pkg/satellite/controllers/snapshot_fetcher.go @@ -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) 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 8e05f73b..e191458e 100644 --- a/pkg/satellite/reconciler.go +++ b/pkg/satellite/reconciler.go @@ -167,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 @@ -187,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 @@ -200,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 @@ -3612,12 +3614,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()) } } @@ -4412,12 +4412,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()) } } 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/store/k8s/spellings.go b/pkg/store/k8s/spellings.go index f4ae580f..23e2ef4f 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -80,6 +80,20 @@ func SameName(a, b string) bool { return Name(a) == Name(b) } +// 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 { From 84f79925fd5b9bde7b2edeb9809861924e378908 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:36:11 +0100 Subject: [PATCH 26/51] feat(naming): give LINSTOR names one key, in a package of its own An object is stored under the CR name its LINSTOR name maps to: the lowercase name inside RFC 1123, a hash of the name as given otherwise. That function lived in the Kubernetes store, so code above the store reached for it, or folded case itself, which is not the same thing for a name outside RFC 1123. pkg/naming now holds the key and Same, and k8s.Name delegates to it. Key is also made idempotent on every key it returns: a name long enough to be cut could end on a dash, which no stored object can carry and which Key hashed a second time. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/naming/naming.go | 106 ++++++++++++++++++++++++++++++++++++++ pkg/naming/naming_test.go | 76 +++++++++++++++++++++++++++ pkg/store/k8s/crdname.go | 78 +++------------------------- 3 files changed, 188 insertions(+), 72 deletions(-) create mode 100644 pkg/naming/naming.go create mode 100644 pkg/naming/naming_test.go diff --git a/pkg/naming/naming.go b/pkg/naming/naming.go new file mode 100644 index 00000000..7937afe3 --- /dev/null +++ b/pkg/naming/naming.go @@ -0,0 +1,106 @@ +// 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]) + "-" + slug + + if len(out) > maxName { + // A cut that ends on a dash is not a name the API server takes, + // and Key of it would hash it again; trimming keeps Key + // idempotent on every key it returns. + 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..c8f57e32 --- /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/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 -) From 5e12a281d0b07b67a4424b72d2a04e93b69fa8b1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:39:20 +0100 Subject: [PATCH 27/51] refactor(naming): take object names through pkg/naming above the store Every controller, REST handler, the satellite, the dispatcher, the placer and the CLI keyed and compared names through k8s.Name and k8s.SameName, reaching into the store backend for it. They now use naming.Key and naming.Same, the same function, so nothing changes in behaviour; the next change removes the per-site helpers and the case folding that sit beside them. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/cli/snapshot.go | 4 ++-- internal/cli/write_more.go | 4 ++-- .../controller/auto_diskful_controller.go | 6 ++--- .../controller/mixed_case_replicas_test.go | 10 ++++---- internal/controller/node_set_identity_test.go | 5 ++-- .../controller/node_spelling_lookups_test.go | 7 +++--- internal/controller/replica_of.go | 10 ++++---- internal/controller/resource_controller.go | 4 ++-- .../resource_state_projection_controller.go | 4 ++-- .../resourcedefinition_controller.go | 24 +++++++++---------- .../resourcedefinition_layer_stack.go | 6 ++--- internal/controller/snapshot_controller.go | 6 ++--- internal/controller/stored_cr_names_test.go | 9 +++---- pkg/dispatcher/dispatcher.go | 16 ++++++------- pkg/dispatcher/node_spelling_test.go | 11 +++++---- pkg/effectiveprops/effective.go | 4 ++-- pkg/placer/placer.go | 4 ++-- pkg/rest/advise.go | 4 ++-- pkg/rest/autoplace.go | 8 +++---- pkg/rest/error_reports.go | 4 ++-- pkg/rest/input_validation_bug_94_97_test.go | 2 +- pkg/rest/name_spelling_cr_test.go | 5 ++-- pkg/rest/node_lifecycle.go | 12 +++++----- pkg/rest/nodes.go | 2 +- pkg/rest/peer_delete_sync.go | 4 ++-- pkg/rest/physical_storage.go | 4 ++-- pkg/rest/query_size_info.go | 6 ++--- pkg/rest/resource_definitions.go | 2 +- pkg/rest/resource_groups.go | 2 +- pkg/rest/snapshots.go | 8 +++---- pkg/rest/storage_pools.go | 4 ++-- pkg/satellite/controllers/cr_names.go | 5 ++-- pkg/satellite/controllers/layer_stack_pin.go | 4 ++-- .../mixed_case_replica_apply_test.go | 5 ++-- pkg/satellite/controllers/node_name.go | 4 ++-- .../controllers/observer_cr_name_test.go | 19 ++++++++------- .../controllers/physicaldevice_discovery.go | 4 ++-- pkg/satellite/controllers/resource.go | 20 ++++++++-------- .../controllers/resource_toggle_disk.go | 4 ++-- pkg/satellite/controllers/snapshot.go | 12 +++++----- pkg/satellite/controllers/snapshot_fetcher.go | 4 ++-- .../controllers/snapshot_fetcher_test.go | 4 ++-- pkg/satellite/controllers/storage_sweeper.go | 4 ++-- pkg/satellite/controllers/sweeper.go | 4 ++-- pkg/satellite/reconciler.go | 4 ++-- pkg/validate/snapshot.go | 6 ++--- 46 files changed, 156 insertions(+), 148 deletions(-) diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 53a71f08..f99d5efe 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -32,7 +32,7 @@ import ( "github.com/cozystack/blockstor/pkg/validate" "github.com/cozystack/blockstor/internal/cli/command" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // errRollbackUnsupported explains a deliberate omission. @@ -1318,7 +1318,7 @@ func sourcePoolOn(ctx context.Context, run *runContext, srcRD, node string) (str continue } - if k8s.SameName(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 defa80bf..daa984ec 100644 --- a/internal/cli/write_more.go +++ b/internal/cli/write_more.go @@ -33,7 +33,7 @@ import ( "github.com/cozystack/blockstor/pkg/validate" "github.com/cozystack/blockstor/internal/cli/command" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // kibPerMib is the binary step between adjacent size suffixes. The @@ -765,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 k8s.SameName(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 f45cc4d9..a7fb07db 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.go @@ -31,8 +31,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" ) // AutoDiskfulReconciler implements the `DrbdOptions/auto-diskful=` @@ -464,7 +464,7 @@ func splitDiskfulAndCandidates(replicas []apiv1.Resource, disabled map[string]st ) for i := range replicas { - if _, off := disabled[k8s.Name(replicas[i].NodeName)]; off { + if _, off := disabled[naming.Key(replicas[i].NodeName)]; off { // Replica on an EVICTED/LOST node — treat as absent. continue } @@ -507,7 +507,7 @@ func (r *AutoDiskfulReconciler) disabledNodeSet(ctx context.Context) (map[string for i := range nodes { if isDisabledNode(&nodes[i]) { - out[k8s.Name(nodes[i].Name)] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} } } diff --git a/internal/controller/mixed_case_replicas_test.go b/internal/controller/mixed_case_replicas_test.go index 2c7159e2..eaf580cd 100644 --- a/internal/controller/mixed_case_replicas_test.go +++ b/internal/controller/mixed_case_replicas_test.go @@ -26,12 +26,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) func mixedCaseReplica(rd, node string) *blockstoriov1alpha1.Resource { return &blockstoriov1alpha1.Resource{ - ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(rd) + "." + node}, + ObjectMeta: metav1.ObjectMeta{Name: naming.Key(rd) + "." + node}, Spec: blockstoriov1alpha1.ResourceSpec{ResourceDefinitionName: rd, NodeName: node}, } } @@ -50,7 +50,7 @@ func TestControllerSeesReplicasOfAMixedCaseDefinition(t *testing.T) { r := &ResourceDefinitionReconciler{Client: cli, APIReader: cli} - got, err := r.listReplicasDirect(context.Background(), k8s.Name("Pvc-Mix")) + got, err := r.listReplicasDirect(context.Background(), naming.Key("Pvc-Mix")) if err != nil { t.Fatalf("list: %v", err) } @@ -68,7 +68,7 @@ func TestReplicaEventEnqueuesTheFoldedDefinition(t *testing.T) { r := &ResourceDefinitionReconciler{} reqs := r.enqueueRDForResource(context.Background(), mixedCaseReplica("Pvc-Mix", "n1")) - if len(reqs) != 1 || reqs[0].Name != k8s.Name("Pvc-Mix") { - t.Errorf("enqueued %v, want %s", reqs, k8s.Name("Pvc-Mix")) + 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/node_set_identity_test.go b/internal/controller/node_set_identity_test.go index 392eddb2..3c755d40 100644 --- a/internal/controller/node_set_identity_test.go +++ b/internal/controller/node_set_identity_test.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/store/k8s" ) @@ -39,12 +40,12 @@ import ( func TestNodeProbeKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { t.Parallel() - node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Node")}} + 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, k8s.Name("My_Node"): true, "my_node": false} { + 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) diff --git a/internal/controller/node_spelling_lookups_test.go b/internal/controller/node_spelling_lookups_test.go index 4fa9345b..3f838910 100644 --- a/internal/controller/node_spelling_lookups_test.go +++ b/internal/controller/node_spelling_lookups_test.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/store/k8s" ) @@ -46,7 +47,7 @@ func TestEvacuationFindsTheReplicaUnderEverySpelling(t *testing.T) { }{ "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", k8s.Name("my_node")}, + "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) { @@ -56,7 +57,7 @@ func TestEvacuationFindsTheReplicaUnderEverySpelling(t *testing.T) { ctx := context.Background() if tc.node != "Node-1" { - node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(tc.node)}} + 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 { @@ -184,7 +185,7 @@ func TestMigrationPrunesASourceOnANodeOutsideRFC1123(t *testing.T) { cli := mixedCaseStand(t, interceptor.Funcs{}) ctx := context.Background() - node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("my_node")}} + 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 { diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go index 42ad251e..3a757140 100644 --- a/internal/controller/replica_of.go +++ b/internal/controller/replica_of.go @@ -26,7 +26,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" ) // isReplicaOf reports whether res belongs to the definition stored as @@ -35,14 +35,14 @@ import ( // 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 k8s.Name(res.Spec.ResourceDefinitionName) == rdName + return naming.Key(res.Spec.ResourceDefinitionName) == rdName } // sameDefinition reports whether two spellings of a definition's name, as // two replicas or a replica and a snapshot carry them, name the same // definition: the one the store keeps under their common folded name. func sameDefinition(a, b string) bool { - return k8s.Name(a) == k8s.Name(b) + return naming.Key(a) == naming.Key(b) } // sameNode reports whether two spellings of a node's name refer to the same @@ -52,7 +52,7 @@ func sameDefinition(a, b string) bool { // spelling too. Both sides map to the node's CR name, which is how the // store addresses it. func sameNode(a, b string) bool { - return k8s.SameName(a, b) + return naming.Same(a, b) } // getReplica reads the replica of definition rd on node into out, whichever @@ -62,7 +62,7 @@ func sameNode(a, b string) bool { // 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: k8s.Name(rd + "." + node)}, out) + err := c.Get(ctx, client.ObjectKey{Name: naming.Key(rd + "." + node)}, out) if err == nil || !apierrors.IsNotFound(err) { return err } diff --git a/internal/controller/resource_controller.go b/internal/controller/resource_controller.go index d3c10799..bfc3a821 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -40,8 +40,8 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // resourceFinalizer is the legacy controller-side finalizer the @@ -283,7 +283,7 @@ func (r *ResourceReconciler) handleOrphan(ctx context.Context, target *blockstor var rd blockstoriov1alpha1.ResourceDefinition - err := r.Get(ctx, client.ObjectKey{Name: k8s.Name(target.Spec.ResourceDefinitionName)}, &rd) + err := r.Get(ctx, client.ObjectKey{Name: naming.Key(target.Spec.ResourceDefinitionName)}, &rd) if err == nil { return false, nil } diff --git a/internal/controller/resource_state_projection_controller.go b/internal/controller/resource_state_projection_controller.go index ce692f95..fc1a7773 100644 --- a/internal/controller/resource_state_projection_controller.go +++ b/internal/controller/resource_state_projection_controller.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // ResourceStateProjectionFieldOwner is the SSA field-manager identity @@ -122,7 +122,7 @@ func (r *ResourceStateProjectionReconciler) Reconcile(ctx context.Context, req c // 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: k8s.Name(res.Spec.NodeName)}, &node) + err = r.Get(ctx, types.NamespacedName{Name: naming.Key(res.Spec.NodeName)}, &node) if err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil diff --git a/internal/controller/resourcedefinition_controller.go b/internal/controller/resourcedefinition_controller.go index 293317a8..401230fb 100644 --- a/internal/controller/resourcedefinition_controller.go +++ b/internal/controller/resourcedefinition_controller.go @@ -41,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 @@ -991,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() } @@ -1279,7 +1279,7 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( ) (string, error) { excluded := make(map[string]bool, len(hostingReplica)) for name, hosting := range hostingReplica { - excluded[k8s.Name(name)] = hosting + excluded[naming.Key(name)] = hosting } if rdName != "" { @@ -1298,7 +1298,7 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( continue } - excluded[k8s.Name(live[i].NodeName)] = true + excluded[naming.Key(live[i].NodeName)] = true } } @@ -1310,7 +1310,7 @@ func (r *ResourceDefinitionReconciler) pickTiebreakerNodeForRD( candidates := make([]string, 0, len(nodes)) for i := range nodes { - if excluded[k8s.Name(nodes[i].Name)] { + if excluded[naming.Key(nodes[i].Name)] { continue } @@ -1445,10 +1445,10 @@ func (r *ResourceDefinitionReconciler) nodeSets(ctx context.Context) (map[string known := make(map[string]struct{}, len(nodes)) for i := range nodes { - known[k8s.Name(nodes[i].Name)] = struct{}{} + known[naming.Key(nodes[i].Name)] = struct{}{} if isDisabledNode(&nodes[i]) { - disabled[k8s.Name(nodes[i].Name)] = struct{}{} + disabled[naming.Key(nodes[i].Name)] = struct{}{} } } @@ -1471,7 +1471,7 @@ func (r *ResourceDefinitionReconciler) confirmGhostReplicas( var live, ghosts []apiv1.Resource for i := range replicas { - if _, ok := known[k8s.Name(replicas[i].NodeName)]; ok { + if _, ok := known[naming.Key(replicas[i].NodeName)]; ok { live = append(live, replicas[i]) continue @@ -1572,13 +1572,13 @@ func (r *ResourceDefinitionReconciler) probeNodeDirect(ctx context.Context, wire // annotation of a CRD named by hand. Folding case instead would read // My_Node and my_node, two CRs, as one node. func nodeCRDMatchesWireName(node *blockstoriov1alpha1.Node, wireName string) bool { - if node.Name == k8s.Name(wireName) { + if node.Name == naming.Key(wireName) { return true } original, ok := node.Annotations[linstorNameAnnotation] - return ok && k8s.SameName(original, wireName) + return ok && naming.Same(original, wireName) } // splitByDisabledNode partitions replicas into (live, stranded) by the @@ -1589,7 +1589,7 @@ func splitByDisabledNode(replicas []apiv1.Resource, disabled map[string]struct{} var live, stranded []apiv1.Resource for i := range replicas { - if _, off := disabled[k8s.Name(replicas[i].NodeName)]; off { + if _, off := disabled[naming.Key(replicas[i].NodeName)]; off { stranded = append(stranded, replicas[i]) continue @@ -1929,6 +1929,6 @@ func (r *ResourceDefinitionReconciler) enqueueRDForResource(_ context.Context, o return []reconcile.Request{ // The RD CR lives under the folded name; the replica keeps the // spelling it was written with. - {NamespacedName: types.NamespacedName{Name: k8s.Name(res.Spec.ResourceDefinitionName)}}, + {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 567796d7..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 } @@ -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/snapshot_controller.go b/internal/controller/snapshot_controller.go index 2839b2aa..8258b41d 100644 --- a/internal/controller/snapshot_controller.go +++ b/internal/controller/snapshot_controller.go @@ -33,7 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // SnapshotReconciler orchestrates the Bug-351 cross-satellite @@ -694,11 +694,11 @@ func allTargetsMatch( // node-1 are one node. byNode := make(map[string]blockstoriov1alpha1.SnapshotPerNodeStatus, len(entries)) for i := range entries { - byNode[k8s.Name(entries[i].NodeName)] = entries[i] + byNode[naming.Key(entries[i].NodeName)] = entries[i] } for _, t := range targets { - entry, ok := byNode[k8s.Name(t)] + entry, ok := byNode[naming.Key(t)] if !ok { return false } diff --git a/internal/controller/stored_cr_names_test.go b/internal/controller/stored_cr_names_test.go index c24f6923..9d4b5d15 100644 --- a/internal/controller/stored_cr_names_test.go +++ b/internal/controller/stored_cr_names_test.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" "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -42,10 +43,10 @@ import ( func mixedCaseStand(t *testing.T, funcs interceptor.Funcs, replicas ...[2]string) client.Client { t.Helper() - rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Mix")}} + rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Pvc-Mix")}} k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") - node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Node-1")}} + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Node-1")}} k8s.SetOriginalName(&node.ObjectMeta, "Node-1") node.Status.ConnectionStatus = blockstoriov1alpha1.NodeConnectionStatusOffline @@ -246,7 +247,7 @@ func TestFirstAvailablePoolMatchesTheReplicasCRNodeName(t *testing.T) { "resource": (&ResourceReconciler{Client: cli, Store: st}).firstAvailablePool, "auto-diskful": (&AutoDiskfulReconciler{Client: cli, Store: st}).firstAvailablePool, } { - got, err := find(ctx, k8s.Name("Node-1")) + got, err := find(ctx, naming.Key("Node-1")) if err != nil { t.Fatalf("%s firstAvailablePool: %v", name, err) } @@ -330,7 +331,7 @@ func TestProjectionReachesAReplicaStoredInTheRegisteredSpelling(t *testing.T) { func TestSameNodeFoldsCase(t *testing.T) { t.Parallel() - for _, pair := range [][2]string{{"Node-1", "node-1"}, {"Node-1", k8s.Name("Node-1")}, {"my_node", k8s.Name("my_node")}} { + for _, pair := range [][2]string{{"Node-1", "node-1"}, {"Node-1", naming.Key("Node-1")}, {"my_node", naming.Key("my_node")}} { if !sameNode(pair[0], pair[1]) { t.Errorf("%s and %s read as two nodes", pair[0], pair[1]) } diff --git a/pkg/dispatcher/dispatcher.go b/pkg/dispatcher/dispatcher.go index 32ae96f6..4f8fa1eb 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 @@ -254,7 +254,7 @@ func assembleDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1 // 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: k8s.Name(target.Spec.ResourceDefinitionName), + Name: naming.Key(target.Spec.ResourceDefinitionName), NodeName: target.Spec.NodeName, Flags: target.Spec.Flags, Props: wireProps, @@ -428,7 +428,7 @@ func readDRBDPort(target *blockstoriov1alpha1.Resource, _ []blockstoriov1alpha1. return int(*target.Status.DRBDPort) } - return derivePort(k8s.Name(target.Spec.ResourceDefinitionName)) + return derivePort(naming.Key(target.Spec.ResourceDefinitionName)) } // readDRBDMinor returns the BASE minor (volume 0) for the resource, @@ -453,7 +453,7 @@ func readDRBDMinor(target *blockstoriov1alpha1.Resource, rd *blockstoriov1alpha1 return int(*target.Status.DRBDMinor) } - return deriveMinor(k8s.Name(target.Spec.ResourceDefinitionName)) + return deriveMinor(naming.Key(target.Spec.ResourceDefinitionName)) } // peerPortOf reads the persisted DRBDPort off a peer Resource. The @@ -673,7 +673,7 @@ func anyDiskfulPeerHasData(peers []blockstoriov1alpha1.Resource) bool { continue } - rdName := k8s.Name(peers[i].Spec.ResourceDefinitionName) + rdName := naming.Key(peers[i].Spec.ResourceDefinitionName) for j := range peers[i].Status.Volumes { vol := &peers[i].Status.Volumes[j] @@ -940,7 +940,7 @@ func restoreSourceOnDisk(value string) string { return value } - return k8s.Name(parts[0]) + ":" + parts[1] + return naming.Key(parts[0]) + ":" + parts[1] } // StorPoolNameDrbdMetaKey is the upstream-LINSTOR prop key that @@ -1151,11 +1151,11 @@ func deriveMinor(rd string) int { // adopted, may carry the registered spelling; both map to the node's CR // name, which is how the store addresses it. func isNodeCalled(node *blockstoriov1alpha1.Node, name string) bool { - return node.Name == k8s.Name(name) + return node.Name == naming.Key(name) } // sameNodeName compares two node names the way the store addresses a // node, for rows that store a name rather than the Node itself. func sameNodeName(a, b string) bool { - return k8s.Name(a) == k8s.Name(b) + return naming.Key(a) == naming.Key(b) } diff --git a/pkg/dispatcher/node_spelling_test.go b/pkg/dispatcher/node_spelling_test.go index eeac76cd..5172c471 100644 --- a/pkg/dispatcher/node_spelling_test.go +++ b/pkg/dispatcher/node_spelling_test.go @@ -25,13 +25,14 @@ import ( 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: k8s.Name(registered)}, Spec: spec} + node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(registered)}, Spec: spec} k8s.SetOriginalName(&node.ObjectMeta, registered) return node @@ -39,7 +40,7 @@ func registeredNode(registered string, spec blockstoriov1alpha1.NodeSpec) blocks func replicaOn(node string, id int32) blockstoriov1alpha1.Resource { return blockstoriov1alpha1.Resource{ - ObjectMeta: metav1.ObjectMeta{Name: "pvc-1." + k8s.Name(node)}, + ObjectMeta: metav1.ObjectMeta{Name: "pvc-1." + naming.Key(node)}, Spec: blockstoriov1alpha1.ResourceSpec{ ResourceDefinitionName: "pvc-1", NodeName: node, @@ -61,9 +62,9 @@ func TestPeerAddressResolvesUnderEverySpellingOfItsNode(t *testing.T) { stored string }{ {name: "lowercase", registered: "node-2", stored: "node-2"}, - {name: "mixed case, CR name stored", registered: "Node-2", stored: k8s.Name("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: k8s.Name("my_node")}, + {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) { @@ -114,7 +115,7 @@ func TestPeerPrefNicResolvesUnderTheCRNameOfItsNode(t *testing.T) { const replication = "192.0.2.22" target := replicaOn("node-1", 0) - peer := replicaOn(k8s.Name("Node-2"), 1) + 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{ 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/placer/placer.go b/pkg/placer/placer.go index 9d28c808..e89ffb44 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -35,8 +35,8 @@ 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/store/k8s" ) // CapacityShortfallError reports that no candidate pool has enough @@ -449,7 +449,7 @@ func (p *Placer) placeDiskful(ctx context.Context, rdName string, state *state, // created with, node-1. Both map to the node's CR name, which is how the // store addresses the node, so they are one key. func nodeKey(name string) string { - return k8s.Name(name) + return naming.Key(name) } // placeDisklessOnRemaining ensures every healthy node not already diff --git a/pkg/rest/advise.go b/pkg/rest/advise.go index 841bf8c5..05d81cfd 100644 --- a/pkg/rest/advise.go +++ b/pkg/rest/advise.go @@ -23,7 +23,7 @@ import ( "net/http" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // registerAdvise wires `linstor advise resource` and the cluster-wide @@ -147,7 +147,7 @@ func pickAdvicePools(pools []apiv1.StoragePool, filter *apiv1.AutoSelectFilter, continue } - if _, off := disabled[k8s.Name(pool.NodeName)]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { continue } diff --git a/pkg/rest/autoplace.go b/pkg/rest/autoplace.go index ef541279..c370323b 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -34,9 +34,9 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // AutoTiebreakerSuppressedUntilAnnotation is re-exported from @@ -1435,7 +1435,7 @@ func (s *Server) handleResourceCreateOnNode(w http.ResponseWriter, r *http.Reque switch { case env.Resource.NodeName == "": env.Resource.NodeName = node - case !k8s.SameName(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+"'") @@ -2042,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 k8s.SameName(siblings[i].NodeName, takeoverNode) { + if naming.Same(siblings[i].NodeName, takeoverNode) { continue } @@ -2915,7 +2915,7 @@ func (s *Server) bumpPeerChangedOnSiblings(ctx context.Context, rdName, removedN for i := range siblings { sib := &siblings[i] - if k8s.SameName(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 b4a34737..9a1d7407 100644 --- a/pkg/rest/error_reports.go +++ b/pkg/rest/error_reports.go @@ -27,7 +27,7 @@ import ( "sync" "time" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // errorReportRingCap caps the in-memory buffer at 1000 entries — the @@ -221,7 +221,7 @@ func (s *Server) handleErrorReportsList(w http.ResponseWriter, r *http.Request) filtered := entries[:0] for i := range entries { - if k8s.SameName(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/name_spelling_cr_test.go b/pkg/rest/name_spelling_cr_test.go index 8529676d..72d45b91 100644 --- a/pkg/rest/name_spelling_cr_test.go +++ b/pkg/rest/name_spelling_cr_test.go @@ -35,6 +35,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" "github.com/cozystack/blockstor/pkg/store/k8s" ) @@ -59,7 +60,7 @@ func TestAFailedParentReadNeverLetsADeletePassItsSnapshots(t *testing.T) { t.Parallel() for fail := int32(1); fail <= 8; fail++ { - rd := crdv1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Reg")}} + 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{ @@ -126,7 +127,7 @@ func TestSnapshotSuccessMatchesAnEntryUnderTheCRName(t *testing.T) { diskful := map[string]struct{}{nodeKey("My_Node"): {}} snap := &apiv1.Snapshot{ Nodes: []string{"My_Node"}, - Snapshots: []apiv1.SnapshotPerNode{{NodeName: k8s.Name("My_Node"), CreateTimestamp: 1}}, + Snapshots: []apiv1.SnapshotPerNode{{NodeName: naming.Key("My_Node"), CreateTimestamp: 1}}, } if !isSnapshotSuccessful(snap, diskful) { diff --git a/pkg/rest/node_lifecycle.go b/pkg/rest/node_lifecycle.go index 208bf9f4..4b26353e 100644 --- a/pkg/rest/node_lifecycle.go +++ b/pkg/rest/node_lifecycle.go @@ -30,8 +30,8 @@ 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/store/k8s" ) // registerNodeLifecycle wires the eviction / restore / lost endpoints. @@ -135,7 +135,7 @@ func (s *Server) handleNodeEvacuate(w http.ResponseWriter, r *http.Request) { for i := range resources { res := &resources[i] - if !k8s.SameName(res.NodeName, name) { + if !naming.Same(res.NodeName, name) { continue } @@ -290,7 +290,7 @@ func queryFlag(r *http.Request, name string) bool { func evacuatingSet(names []string) map[string]bool { out := make(map[string]bool, len(names)) for _, name := range names { - out[k8s.Name(name)] = true + out[naming.Key(name)] = true } return out @@ -331,7 +331,7 @@ func (s *Server) checkEvacuateCandidate(ctx context.Context, w http.ResponseWrit for i := range allNodes { n := &allNodes[i] - if evacuating[k8s.Name(n.Name)] { + if evacuating[naming.Key(n.Name)] { continue } @@ -367,7 +367,7 @@ func (s *Server) checkEvacuateInUse(ctx context.Context, w http.ResponseWriter, for i := range resources { res := &resources[i] - if !evacuating[k8s.Name(res.NodeName)] { + if !evacuating[naming.Key(res.NodeName)] { continue } @@ -667,5 +667,5 @@ func removeFlag(flag string) func([]string) []string { // variants of a name outside RFC 1123, My_Node and my_node, hash to two CR // names and stay two nodes. func nodeKey(name string) string { - return k8s.Name(name) + return naming.Key(name) } 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 7d84d8b0..c2815a8d 100644 --- a/pkg/rest/peer_delete_sync.go +++ b/pkg/rest/peer_delete_sync.go @@ -24,8 +24,8 @@ import ( "time" 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" ) // peerDeleteAckTimeout bounds how long handleResourceDelete waits for @@ -106,7 +106,7 @@ func (s *Server) waitForPeerDeletionAcks(ctx context.Context, rdName, removedNod for i := range siblings { sib := &siblings[i] - if k8s.SameName(sib.NodeName, removedNode) { + if naming.Same(sib.NodeName, removedNode) { continue } diff --git a/pkg/rest/physical_storage.go b/pkg/rest/physical_storage.go index daaa44af..af11bd93 100644 --- a/pkg/rest/physical_storage.go +++ b/pkg/rest/physical_storage.go @@ -31,8 +31,8 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // Canonical upstream-LINSTOR `DeviceProviderKind` enum values. Mirrors @@ -443,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 k8s.SameName(p.Spec.NodeName, nodeName) && p.Spec.PoolName == poolName { + if naming.Same(p.Spec.NodeName, nodeName) && p.Spec.PoolName == poolName { return p } } diff --git a/pkg/rest/query_size_info.go b/pkg/rest/query_size_info.go index 37dbd605..8cb06ee3 100644 --- a/pkg/rest/query_size_info.go +++ b/pkg/rest/query_size_info.go @@ -24,7 +24,7 @@ import ( "net/http" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // registerQuerySizeInfo wires `linstor query-size-info` and the @@ -180,7 +180,7 @@ func collectCandidates(pools []apiv1.StoragePool, disabled map[string]struct{}, continue } - if _, off := disabled[k8s.Name(pool.NodeName)]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { continue } @@ -371,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[k8s.Name(nodes[i].Name)] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} break } diff --git a/pkg/rest/resource_definitions.go b/pkg/rest/resource_definitions.go index 60d0bd58..e4a3afe6 100644 --- a/pkg/rest/resource_definitions.go +++ b/pkg/rest/resource_definitions.go @@ -432,7 +432,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; diff --git a/pkg/rest/resource_groups.go b/pkg/rest/resource_groups.go index dec8f246..9888af23 100644 --- a/pkg/rest/resource_groups.go +++ b/pkg/rest/resource_groups.go @@ -165,7 +165,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 { diff --git a/pkg/rest/snapshots.go b/pkg/rest/snapshots.go index feb521e6..f24ba2b5 100644 --- a/pkg/rest/snapshots.go +++ b/pkg/rest/snapshots.go @@ -28,8 +28,8 @@ 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/store/k8s" ) // registerSnapshots wires snapshot endpoints. Three different paths land @@ -730,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/naming.Key() slugifies the input. snapNameErr := validateLinstorName("snapshot", strings.TrimSpace(snap.Name)) if snapNameErr != nil { writeError(w, http.StatusBadRequest, snapNameErr.Error()) @@ -1070,7 +1070,7 @@ func (s *Server) nonSnapshotPoolLocations(ctx context.Context, resList []apiv1.R // 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[k8s.Name(n)] = struct{}{} + want[naming.Key(n)] = struct{}{} } var offenders []poolOffender @@ -1082,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[k8s.Name(res.NodeName)]; !ok { + if _, ok := want[naming.Key(res.NodeName)]; !ok { continue } } diff --git a/pkg/rest/storage_pools.go b/pkg/rest/storage_pools.go index ef53a780..cbee981e 100644 --- a/pkg/rest/storage_pools.go +++ b/pkg/rest/storage_pools.go @@ -28,8 +28,8 @@ 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/store/k8s" "github.com/cozystack/blockstor/pkg/validate" ) @@ -370,7 +370,7 @@ func (s *Server) referencingResources(ctx context.Context, node, pool string) ([ var refs []string for i := range all { - if !k8s.SameName(all[i].NodeName, node) { + if !naming.Same(all[i].NodeName, node) { continue } diff --git a/pkg/satellite/controllers/cr_names.go b/pkg/satellite/controllers/cr_names.go index dde6f466..1c119eb7 100644 --- a/pkg/satellite/controllers/cr_names.go +++ b/pkg/satellite/controllers/cr_names.go @@ -26,6 +26,7 @@ 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/store/k8s" ) @@ -46,7 +47,7 @@ func replicaCRName(ctx context.Context, reader client.Reader, rdName, node strin var crd blockstoriov1alpha1.Node - err = reader.Get(ctx, client.ObjectKey{Name: k8s.Name(node)}, &crd) + err = reader.Get(ctx, client.ObjectKey{Name: naming.Key(node)}, &crd) switch { case err == nil: @@ -74,7 +75,7 @@ func snapshotCRName(ctx context.Context, reader client.Reader, rdName, snap stri func registeredRDName(ctx context.Context, reader client.Reader, rdName string) (string, error) { var rd blockstoriov1alpha1.ResourceDefinition - err := reader.Get(ctx, client.ObjectKey{Name: k8s.Name(rdName)}, &rd) + err := reader.Get(ctx, client.ObjectKey{Name: naming.Key(rdName)}, &rd) if apierrors.IsNotFound(err) { return rdName, nil } 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/mixed_case_replica_apply_test.go b/pkg/satellite/controllers/mixed_case_replica_apply_test.go index a860346b..4858d6b0 100644 --- a/pkg/satellite/controllers/mixed_case_replica_apply_test.go +++ b/pkg/satellite/controllers/mixed_case_replica_apply_test.go @@ -34,6 +34,7 @@ import ( 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" ) @@ -50,10 +51,10 @@ func storedMixedCaseReplica(t *testing.T, funcs interceptor.Funcs) (client.Clien t.Fatalf("AddToScheme: %v", err) } - rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Pvc-Mix")}} + rd := blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("Pvc-Mix")}} k8s.SetOriginalName(&rd.ObjectMeta, "Pvc-Mix") - node := blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("Node-1")}} + 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() diff --git a/pkg/satellite/controllers/node_name.go b/pkg/satellite/controllers/node_name.go index 02153bf5..8f9de1a0 100644 --- a/pkg/satellite/controllers/node_name.go +++ b/pkg/satellite/controllers/node_name.go @@ -21,7 +21,7 @@ package controllers import ( "slices" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // isNode reports whether name, as a replica or a pool spells its node, is @@ -31,7 +31,7 @@ import ( // replica written or adopted before the store and linstor-migrate stored // the node's CR name may carry Node-1: either is this satellite's. func isNode(name, node string) bool { - return k8s.SameName(name, node) + return naming.Same(name, node) } // listsNode reports whether a snapshot's node list, which carries the diff --git a/pkg/satellite/controllers/observer_cr_name_test.go b/pkg/satellite/controllers/observer_cr_name_test.go index 0fbafc68..8def76a6 100644 --- a/pkg/satellite/controllers/observer_cr_name_test.go +++ b/pkg/satellite/controllers/observer_cr_name_test.go @@ -31,6 +31,7 @@ import ( "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" ) @@ -46,7 +47,7 @@ func TestTheObserverWritesOntoTheReplicaTheStoreNamed(t *testing.T) { specRD string node string }{ - {"My_Res", k8s.Name("My_Res"), "Node-1"}, + {"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"}, @@ -59,10 +60,10 @@ func TestTheObserverWritesOntoTheReplicaTheStoreNamed(t *testing.T) { t.Fatalf("scheme: %v", err) } - rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(tc.registered)}} + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(tc.registered)}} k8s.SetOriginalName(&rd.ObjectMeta, tc.registered) - node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name(tc.node)}} + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key(tc.node)}} k8s.SetOriginalName(&node.ObjectMeta, tc.node) crName := k8s.ResourceCRName(tc.registered, tc.node) @@ -80,7 +81,7 @@ func TestTheObserverWritesOntoTheReplicaTheStoreNamed(t *testing.T) { o := &ObserverRunnable{Client: cli, NodeName: node.Name} ctx := context.Background() - kernel := k8s.Name(tc.specRD) + kernel := naming.Key(tc.specRD) if err := o.writeStatus(ctx, &observation{ResourceName: kernel, DrbdState: "UpToDate", Role: "Secondary"}); err != nil { t.Fatalf("writeStatus: %v", err) @@ -135,7 +136,7 @@ func crNameScheme(t *testing.T) *runtime.Scheme { func TestTheSkipDiskClearerReachesAHashedDefinitionsReplica(t *testing.T) { t.Parallel() - rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Res")}} + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") crName := k8s.ResourceCRName("My_Res", "n1") @@ -178,7 +179,7 @@ func TestTheSkipDiskClearerReachesAHashedDefinitionsReplica(t *testing.T) { func TestTheSnapshotFetcherFindsAHashedDefinitionsSnapshot(t *testing.T) { t.Parallel() - rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Res")}} + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") snap := &blockstoriov1alpha1.Snapshot{ @@ -199,7 +200,7 @@ func TestTheSnapshotFetcherFindsAHashedDefinitionsSnapshot(t *testing.T) { func TestTheObserverWakesAHashedDefinitionsReplica(t *testing.T) { t.Parallel() - rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Res")}} + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") trigger := make(chan event.GenericEvent, 1) @@ -226,10 +227,10 @@ func TestTheObserverWakesAHashedDefinitionsReplica(t *testing.T) { func TestTheConditionStampersReachAHashedDefinitionsReplica(t *testing.T) { t.Parallel() - rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Res")}} + rd := &blockstoriov1alpha1.ResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Res")}} k8s.SetOriginalName(&rd.ObjectMeta, "My_Res") - node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: k8s.Name("My_Node")}} + node := &blockstoriov1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: naming.Key("My_Node")}} k8s.SetOriginalName(&node.ObjectMeta, "My_Node") crName := k8s.ResourceCRName("My_Res", "My_Node") diff --git a/pkg/satellite/controllers/physicaldevice_discovery.go b/pkg/satellite/controllers/physicaldevice_discovery.go index 2dbe14ed..f65ba37d 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 diff --git a/pkg/satellite/controllers/resource.go b/pkg/satellite/controllers/resource.go index 5d16b028..f6786e3f 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -50,8 +50,8 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // kubeInternalNICName is the NetInterface name the dispatcher prefers @@ -421,7 +421,7 @@ func (r *ResourceReconciler) enqueueResourcesForRD(ctx context.Context, obj clie for i := range localList.Items { local := &localList.Items[i] - if k8s.SameName(local.Spec.ResourceDefinitionName, rd.Name) && + if naming.Same(local.Spec.ResourceDefinitionName, rd.Name) && isNode(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } @@ -439,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: k8s.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 @@ -1068,7 +1068,7 @@ func (r *ResourceReconciler) enqueueLocalSiblings(ctx context.Context, obj clien for i := range localList.Items { local := &localList.Items[i] - if k8s.SameName(local.Spec.ResourceDefinitionName, res.Spec.ResourceDefinitionName) && + if naming.Same(local.Spec.ResourceDefinitionName, res.Spec.ResourceDefinitionName) && isNode(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } @@ -1198,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 k8s.SameName(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]) } } @@ -1228,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 k8s.SameName(peers[i].Spec.NodeName, target.Spec.NodeName) && - k8s.SameName(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 @@ -1408,7 +1408,7 @@ func peersMissingNodeID(target *blockstoriov1alpha1.Resource, peers []blockstori for i := range peers { peer := &peers[i] - if k8s.SameName(peer.Spec.NodeName, target.Spec.NodeName) { + if naming.Same(peer.Spec.NodeName, target.Spec.NodeName) { continue } @@ -1476,7 +1476,7 @@ func (r *ResourceReconciler) handleDelete(ctx context.Context, res *blockstoriov } req := &intent.DeleteResourceRequest{ - Name: k8s.Name(res.Spec.ResourceDefinitionName), + Name: naming.Key(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } @@ -1575,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: k8s.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 { diff --git a/pkg/satellite/controllers/resource_toggle_disk.go b/pkg/satellite/controllers/resource_toggle_disk.go index 90fd94ff..e128679b 100644 --- a/pkg/satellite/controllers/resource_toggle_disk.go +++ b/pkg/satellite/controllers/resource_toggle_disk.go @@ -33,8 +33,8 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // ToggleDiskRetryCap is the soft upper bound the reconciler surfaces @@ -213,7 +213,7 @@ func (r *ResourceReconciler) handleToggleDiskCancel(ctx context.Context, res *bl } req := &intent.DeleteResourceRequest{ - Name: k8s.Name(res.Spec.ResourceDefinitionName), + Name: naming.Key(res.Spec.ResourceDefinitionName), StoragePool: resolveDeleteStoragePool(res), VolumeNumbers: volumeNumbers, } diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index 745f1c43..c1c68a53 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -33,8 +33,8 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // SatelliteSnapshotFinalizer guards a Snapshot CRD while one of @@ -212,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, k8s.Name(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()) @@ -247,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, k8s.Name(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()) @@ -272,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: k8s.Name(snap.Spec.ResourceDefinitionName), + ResourceName: naming.Key(snap.Spec.ResourceDefinitionName), SnapshotName: snap.Spec.SnapshotName, } @@ -299,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, k8s.Name(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 @@ -698,7 +698,7 @@ func (r *SnapshotReconciler) handleDelete(ctx context.Context, snap *blockstorio } req := &intent.DeleteSnapshotRequest{ - ResourceName: k8s.Name(snap.Spec.ResourceDefinitionName), + ResourceName: naming.Key(snap.Spec.ResourceDefinitionName), SnapshotName: snap.Spec.SnapshotName, } diff --git a/pkg/satellite/controllers/snapshot_fetcher.go b/pkg/satellite/controllers/snapshot_fetcher.go index bce19fa9..37b6ed1d 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 @@ -131,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: k8s.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/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index 6a492ef1..bcc1630c 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -31,8 +31,8 @@ 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/store/k8s" ) // StorageSweeperPeriod is the default cadence of the storage-orphan @@ -386,7 +386,7 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) // The volumes are laid down under the definition's CR name, // whatever spelling an older row keeps in spec. - rdName := k8s.Name(r.Spec.ResourceDefinitionName) + rdName := naming.Key(r.Spec.ResourceDefinitionName) vols, ok := rdToVolumes[rdName] if !ok { diff --git a/pkg/satellite/controllers/sweeper.go b/pkg/satellite/controllers/sweeper.go index 51c0d199..9657d71b 100644 --- a/pkg/satellite/controllers/sweeper.go +++ b/pkg/satellite/controllers/sweeper.go @@ -32,7 +32,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" ) // SweeperPeriod is the default cadence at which the orphan sweeper @@ -606,7 +606,7 @@ func (s *OrphanSweeperRunnable) listOwnedResourceNames(ctx context.Context) (map continue } - out[k8s.Name(r.Spec.ResourceDefinitionName)] = struct{}{} + out[naming.Key(r.Spec.ResourceDefinitionName)] = struct{}{} } return out, nil diff --git a/pkg/satellite/reconciler.go b/pkg/satellite/reconciler.go index e191458e..8a3740b2 100644 --- a/pkg/satellite/reconciler.go +++ b/pkg/satellite/reconciler.go @@ -37,9 +37,9 @@ 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" - "github.com/cozystack/blockstor/pkg/store/k8s" ) // ReconcilerConfig parametrises a Reconciler. @@ -5747,7 +5747,7 @@ func buildResConnections(dr *intent.DesiredResource, hosts []drbd.Host) []drbd.R // is when no host is that node. func hostSpelling(hosts []drbd.Host, node string) string { for i := range hosts { - if k8s.SameName(hosts[i].NodeName, node) { + if naming.Same(hosts[i].NodeName, node) { return hosts[i].NodeName } } diff --git a/pkg/validate/snapshot.go b/pkg/validate/snapshot.go index d8ea9b22..a9a7afa2 100644 --- a/pkg/validate/snapshot.go +++ b/pkg/validate/snapshot.go @@ -23,7 +23,7 @@ import ( "fmt" "strings" - "github.com/cozystack/blockstor/pkg/store/k8s" + "github.com/cozystack/blockstor/pkg/naming" ) // ErrRestoreNodeLacksSnapshot refuses a restore onto a node that does not @@ -76,7 +76,7 @@ func RestoreNodesMissingSnapshot(named, snapshotNodes []string) []string { // 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[k8s.Name(n)] = struct{}{} + holds[naming.Key(n)] = struct{}{} } seen := make(map[string]struct{}, len(named)) @@ -87,7 +87,7 @@ func RestoreNodesMissingSnapshot(named, snapshotNodes []string) []string { continue } - key := k8s.Name(n) + key := naming.Key(n) if _, dup := seen[key]; dup { continue } From b4f1a4e1f80d27e0828f0bfa90cc4e4abefbaabe Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:43:31 +0100 Subject: [PATCH 28/51] fix(naming): compare and key object names only as naming keys Code compared two spellings of a name as they were, or folded case where the store hashes, in more places than any single fix reached. They came from one habit: every site decided for itself how to compare a node, definition, group, pool or snapshot name. Above the store they are now compared with naming.Same and used as map keys through naming.Key, and nothing else. That removes the per-site helpers (sameNode, sameDefinition, sameNodeName, isNodeCalled, isNode, listsNode, nodeKey, nodeCRDMatchesWireName, k8s.SameName, store.FoldName) and fixes the sites that still mixed spellings, among them: - the evacuation drain target counted a replica on a LOST or EVICTED node when its row spelled the node differently from the node list; - the placer's min-rsc-count weight read a pool's node in the pool's spelling against counts keyed by the replicas' spelling; - list filters, restore markers, group and pool names folded case, which joins two definitions such as My_Res and my_res that the store keeps apart; the in-memory store did the same and now keys like the Kubernetes one. The dispatcher keys peers by node and still emits the spelling a peer's row stores, so no DRBD connection of a running resource is renamed. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/cli/definition.go | 5 +- internal/cli/flags.go | 7 +- internal/cli/handlers.go | 18 +-- internal/cli/name_filter_test.go | 42 +++++ internal/cli/physical.go | 3 +- internal/cli/place.go | 3 +- internal/cli/pool.go | 3 +- internal/cli/snapshot.go | 4 +- .../controller/auto_diskful_controller.go | 2 +- .../controller/autosnapshot_controller.go | 3 +- internal/controller/export_test.go | 5 + .../controller/name_key_evacuation_test.go | 153 ++++++++++++++++++ internal/controller/node_controller.go | 15 +- internal/controller/replica_of.go | 19 +-- internal/controller/resource_controller.go | 20 +-- .../resource_state_projection_controller.go | 2 +- .../resourcedefinition_controller.go | 26 +-- .../controller/resourcegroup_controller.go | 3 +- .../controller/rg_rebalance_controller.go | 13 +- internal/controller/snapshot_controller.go | 4 +- internal/controller/snapshot_timeout.go | 5 +- internal/controller/stored_cr_names_test.go | 12 +- pkg/dispatcher/dispatcher.go | 60 +++---- pkg/placer/name_key_internal_test.go | 60 +++++++ pkg/placer/placer.go | 69 ++++---- pkg/rest/advise.go | 2 +- pkg/rest/autoplace.go | 2 +- pkg/rest/multivalue_query_test.go | 4 +- pkg/rest/n_d_registered_spelling_race_test.go | 3 +- pkg/rest/name_filter_internal_test.go | 41 +++++ pkg/rest/name_spelling_cr_test.go | 8 +- pkg/rest/node_lifecycle.go | 10 -- pkg/rest/node_spelling_wire_test.go | 3 +- pkg/rest/physical_storage.go | 2 +- pkg/rest/query_size_info.go | 2 +- pkg/rest/rd_clone.go | 6 +- pkg/rest/resource_definitions.go | 5 +- pkg/rest/resource_group_extras.go | 17 +- pkg/rest/resource_groups.go | 5 +- pkg/rest/resources.go | 29 ++-- pkg/rest/rg_deleted_race.go | 5 +- pkg/rest/snapshot_restore.go | 26 +-- pkg/rest/snapshots.go | 20 +-- pkg/rest/storage_pool_definitions.go | 3 +- pkg/rest/storage_pools.go | 12 +- pkg/rest/storage_pools_test.go | 2 +- pkg/rest/upstream_parity_bug_225_229.go | 3 +- .../controllers/metadata_created_backfill.go | 3 +- pkg/satellite/controllers/node_name.go | 41 ----- pkg/satellite/controllers/physicaldevice.go | 11 +- .../controllers/physicaldevice_discovery.go | 2 +- pkg/satellite/controllers/resource.go | 18 +-- pkg/satellite/controllers/snapshot.go | 12 +- pkg/satellite/controllers/snapshot_fetcher.go | 2 +- .../snapshot_node_spelling_test.go | 6 +- pkg/satellite/controllers/storage_sweeper.go | 2 +- pkg/satellite/controllers/storagepool.go | 3 +- pkg/satellite/controllers/sweeper.go | 8 +- pkg/satellite/controllers/sweeper_resfile.go | 6 +- pkg/store/cascade.go | 14 +- pkg/store/clone_snapshot.go | 7 +- pkg/store/inmemory_name_key_test.go | 65 ++++++++ pkg/store/inmemory_resource.go | 5 +- pkg/store/inmemory_snapshot.go | 3 +- pkg/store/inmemory_volume_definition.go | 7 +- pkg/store/k8s/field_index_test.go | 4 +- pkg/store/k8s/spellings.go | 11 -- pkg/store/k8s/storage_pools.go | 3 +- pkg/store/k8s/volume_definitions.go | 5 +- pkg/store/leftover.go | 4 +- pkg/store/local_props.go | 21 +++ pkg/store/restore_marker_test.go | 49 ++++++ pkg/store/restore_source.go | 4 +- pkg/store/restore_target.go | 7 +- pkg/store/store.go | 41 +---- pkg/store/storetest/storetest.go | 34 +++- pkg/validate/resource_delete.go | 3 +- 77 files changed, 757 insertions(+), 410 deletions(-) create mode 100644 internal/cli/name_filter_test.go create mode 100644 internal/controller/name_key_evacuation_test.go create mode 100644 pkg/placer/name_key_internal_test.go create mode 100644 pkg/rest/name_filter_internal_test.go delete mode 100644 pkg/satellite/controllers/node_name.go create mode 100644 pkg/store/inmemory_name_key_test.go create mode 100644 pkg/store/restore_marker_test.go diff --git a/internal/cli/definition.go b/internal/cli/definition.go index a7d96832..bfec060c 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) } 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/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/name_filter_test.go b/internal/cli/name_filter_test.go new file mode 100644 index 00000000..15e88e20 --- /dev/null +++ b/internal/cli/name_filter_test.go @@ -0,0 +1,42 @@ +// 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) + } + } +} 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/snapshot.go b/internal/cli/snapshot.go index f99d5efe..9b4cc55a 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -589,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 } @@ -907,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 diff --git a/internal/controller/auto_diskful_controller.go b/internal/controller/auto_diskful_controller.go index a7fb07db..51fb520d 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.go @@ -327,7 +327,7 @@ func (r *AutoDiskfulReconciler) firstAvailablePool(ctx context.Context, nodeName } for i := range pools { - if !sameNode(pools[i].NodeName, nodeName) { + if !naming.Same(pools[i].NodeName, nodeName) { continue } 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/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/name_key_evacuation_test.go b/internal/controller/name_key_evacuation_test.go new file mode 100644 index 00000000..f97401b3 --- /dev/null +++ b/internal/controller/name_key_evacuation_test.go @@ -0,0 +1,153 @@ +// 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) + } +} diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index 57e361db..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 !sameNode(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 } } @@ -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 } @@ -439,7 +440,7 @@ 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{}{} } } diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go index 3a757140..f8b1d9ef 100644 --- a/internal/controller/replica_of.go +++ b/internal/controller/replica_of.go @@ -38,23 +38,6 @@ func isReplicaOf(res *blockstoriov1alpha1.Resource, rdName string) bool { return naming.Key(res.Spec.ResourceDefinitionName) == rdName } -// sameDefinition reports whether two spellings of a definition's name, as -// two replicas or a replica and a snapshot carry them, name the same -// definition: the one the store keeps under their common folded name. -func sameDefinition(a, b string) bool { - return naming.Key(a) == naming.Key(b) -} - -// sameNode reports whether two spellings of a node's name refer to the same -// node. A replica stores its node's CR name, while a snapshot's node list, -// a storage pool and the wire carry the registered spelling, and a replica -// written or adopted before the store stored CR names may carry that -// spelling too. Both sides map to the node's CR name, which is how the -// store addresses it. -func sameNode(a, b string) bool { - return naming.Same(a, b) -} - // 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 @@ -76,7 +59,7 @@ func getReplica(ctx context.Context, c client.Reader, rd, node string, out *bloc for i := range list.Items { item := &list.Items[i] - if sameDefinition(item.Spec.ResourceDefinitionName, rd) && sameNode(item.Spec.NodeName, node) { + if naming.Same(item.Spec.ResourceDefinitionName, rd) && naming.Same(item.Spec.NodeName, node) { *out = *item return nil diff --git a/internal/controller/resource_controller.go b/internal/controller/resource_controller.go index bfc3a821..f34dde3c 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -337,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 sameDefinition(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]) } } @@ -484,7 +484,7 @@ func (r *ResourceReconciler) firstAvailablePool(ctx context.Context, nodeName st } for i := range pools { - if !sameNode(pools[i].NodeName, nodeName) { + if !naming.Same(pools[i].NodeName, nodeName) { continue } @@ -1089,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 } @@ -1171,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 } @@ -1767,11 +1767,11 @@ func (r *ResourceReconciler) hostingNodesForRD(ctx context.Context, rdName strin } 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) } @@ -1857,7 +1857,7 @@ func (r *ResourceReconciler) takenPortsOnNode(ctx context.Context, nodeName, sel for i := range resList.Items { res := &resList.Items[i] - if !sameNode(res.Spec.NodeName, nodeName) || res.Name == selfName { + if !naming.Same(res.Spec.NodeName, nodeName) || res.Name == selfName { continue } @@ -1891,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 } @@ -2216,7 +2216,7 @@ func (r *ResourceReconciler) collectTakenNodeIDs(ctx context.Context, target *bl continue } - if !sameDefinition(res.Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { + if !naming.Same(res.Spec.ResourceDefinitionName, target.Spec.ResourceDefinitionName) { continue } @@ -2285,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 !sameDefinition(resList.Items[i].Spec.ResourceDefinitionName, rdName) { + if !naming.Same(resList.Items[i].Spec.ResourceDefinitionName, rdName) { continue } diff --git a/internal/controller/resource_state_projection_controller.go b/internal/controller/resource_state_projection_controller.go index fc1a7773..96f5c258 100644 --- a/internal/controller/resource_state_projection_controller.go +++ b/internal/controller/resource_state_projection_controller.go @@ -271,7 +271,7 @@ func (r *ResourceStateProjectionReconciler) enqueueResourcesForNode(ctx context. out := make([]reconcile.Request, 0, len(resList.Items)) for i := range resList.Items { - if !sameNode(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 401230fb..e6d41d41 100644 --- a/internal/controller/resourcedefinition_controller.go +++ b/internal/controller/resourcedefinition_controller.go @@ -846,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 @@ -1496,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 { @@ -1553,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 } @@ -1566,21 +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, the way the store addresses it: by the CR -// name the wire name maps to, or by the preserved original-name -// annotation of a CRD named by hand. Folding case instead would read -// My_Node and my_node, two CRs, as one node. -func nodeCRDMatchesWireName(node *blockstoriov1alpha1.Node, wireName string) bool { - if node.Name == naming.Key(wireName) { - return true - } - - original, ok := node.Annotations[linstorNameAnnotation] - - return ok && naming.Same(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 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 8258b41d..fd3aec20 100644 --- a/internal/controller/snapshot_controller.go +++ b/internal/controller/snapshot_controller.go @@ -237,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 sameNode(entries[i].NodeName, node) { + if naming.Same(entries[i].NodeName, node) { return true } } @@ -781,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 683e6178..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 @@ -327,13 +328,13 @@ func (r *SnapshotReconciler) siblingReplicasUpToDate( for i := range resList.Items { res := &resList.Items[i] - if !sameDefinition(res.Spec.ResourceDefinitionName, snap.Spec.ResourceDefinitionName) { + if !naming.Same(res.Spec.ResourceDefinitionName, snap.Spec.ResourceDefinitionName) { continue } // 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 sameNode(n, res.Spec.NodeName) }) { + 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 index 9d4b5d15..90880737 100644 --- a/internal/controller/stored_cr_names_test.go +++ b/internal/controller/stored_cr_names_test.go @@ -177,18 +177,18 @@ func TestProjectionFollowsTheNodeOfAMixedCaseReplica(t *testing.T) { } } -// sameDefinition tells two spellings of one definition from two definitions. +// 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 !sameDefinition("Pvc-Mix", "pvc-mix") { + if !naming.Same("Pvc-Mix", "pvc-mix") { t.Error("Pvc-Mix and pvc-mix read as two definitions") } - if sameDefinition("pvc-mix", "pvc-other") { + if naming.Same("pvc-mix", "pvc-other") { t.Error("pvc-mix and pvc-other read as one definition") } } @@ -325,20 +325,20 @@ func TestProjectionReachesAReplicaStoredInTheRegisteredSpelling(t *testing.T) { } } -// sameNode tells two spellings of one node from two nodes, the way the +// 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 !sameNode(pair[0], pair[1]) { + 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 sameNode(pair[0], pair[1]) { + 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 4f8fa1eb..4193eebc 100644 --- a/pkg/dispatcher/dispatcher.go +++ b/pkg/dispatcher/dispatcher.go @@ -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) @@ -309,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)) @@ -317,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. @@ -393,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 } @@ -478,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 { @@ -486,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) } @@ -501,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 } } @@ -571,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 !sameNodeName(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 } @@ -588,7 +592,7 @@ func lookupPrefNic(nodeName, poolName string, nodes []blockstoriov1alpha1.Node, } for i := range nodes { - if !isNodeCalled(&nodes[i], nodeName) { + if nodes[i].Name != naming.Key(nodeName) { continue } @@ -604,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 !isNodeCalled(&nodes[i], nodeName) { + if nodes[i].Name != naming.Key(nodeName) { continue } @@ -1039,11 +1043,11 @@ const defaultNICName = "default" // legacy "first non-empty" behaviour so single-NIC clusters keep // working unchanged. (Bug 48) // -// Match is by isNodeCalled, so a replica that stores the node's CR name -// and one written before in the registered spelling both 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 !isNodeCalled(&nodes[i], nodeName) { + if nodes[i].Name != naming.Key(nodeName) { continue } @@ -1145,17 +1149,3 @@ func deriveMinor(rd string) int { return minorBase + int(binary.BigEndian.Uint16(digest[2:4])%minorRange) } - -// isNodeCalled reports whether node is the one a replica names as name. -// A replica stores its node's CR name, and one written before that, or -// adopted, may carry the registered spelling; both map to the node's CR -// name, which is how the store addresses it. -func isNodeCalled(node *blockstoriov1alpha1.Node, name string) bool { - return node.Name == naming.Key(name) -} - -// sameNodeName compares two node names the way the store addresses a -// node, for rows that store a name rather than the Node itself. -func sameNodeName(a, b string) bool { - return naming.Key(a) == naming.Key(b) -} diff --git a/pkg/placer/name_key_internal_test.go b/pkg/placer/name_key_internal_test.go new file mode 100644 index 00000000..a9d6da1b --- /dev/null +++ b/pkg/placer/name_key_internal_test.go @@ -0,0 +1,60 @@ +// 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) + } +} diff --git a/pkg/placer/placer.go b/pkg/placer/placer.go index e89ffb44..2496a8c5 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -403,7 +403,7 @@ func countDiskfulReplicas(existing []apiv1.Resource, disabled map[string]struct{ count := 0 for i := range existing { - if _, off := disabled[nodeKey(existing[i].NodeName)]; off { + if _, off := disabled[naming.Key(existing[i].NodeName)]; off { continue } @@ -443,15 +443,6 @@ func (p *Placer) placeDiskful(ctx context.Context, rdName string, state *state, return placed, nil } -// nodeKey is the key a node takes in the placer's node sets and maps. -// Replicas, pools and nodes all come from the wire, and a replica shows its -// node as it was registered, Node-1, while a pool keeps the spelling it was -// created with, node-1. Both map to the node's CR name, which is how the -// store addresses the node, so they are one key. -func nodeKey(name string) string { - return naming.Key(name) -} - // placeDisklessOnRemaining ensures every healthy node not already // hosting a replica gets a DISKLESS one. Upstream LINSTOR uses this // for "cluster-wide attachable" volumes — any consumer Pod can mount @@ -462,7 +453,7 @@ func nodeKey(name string) string { // met. func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, state *state) error { for nodeName, hostProps := range state.nodes { - if _, busy := state.taken[nodeKey(nodeName)]; busy { + if _, busy := state.taken[naming.Key(nodeName)]; busy { continue } @@ -479,7 +470,7 @@ func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, st return errors.Wrap(err, "create diskless witness") } - state.taken[nodeKey(nodeName)] = struct{}{} + state.taken[naming.Key(nodeName)] = struct{}{} } return nil @@ -559,12 +550,12 @@ func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes m // 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[nodeKey(existing[i].NodeName)] = existing[i].Props[storPoolNameProp] + s.upgradeable[naming.Key(existing[i].NodeName)] = existing[i].Props[storPoolNameProp] continue } - s.taken[nodeKey(existing[i].NodeName)] = struct{}{} + s.taken[naming.Key(existing[i].NodeName)] = struct{}{} // Seed XReplicasOnDifferentMap buckets from this diskful // replica. DISKLESS witnesses (handled by the continue above) @@ -572,7 +563,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[nodeKey(existing[i].NodeName)] + nodeProps := nodes[naming.Key(existing[i].NodeName)] for key := range filter.XReplicasOnDifferentMap { s.xBuckets[xBucketKey(key, nodeProps)]++ } @@ -616,11 +607,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[nodeKey(pool.NodeName)]; busy { + if _, busy := s.taken[naming.Key(pool.NodeName)]; busy { return nil, false } - nodeProps := s.nodes[nodeKey(pool.NodeName)] + nodeProps := s.nodes[naming.Key(pool.NodeName)] if !matchesTuple(nodeProps, s.filter.ReplicasOnSame, s.sameTuple) { return nil, false @@ -726,7 +717,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[nodeKey(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 @@ -744,8 +735,8 @@ func (s *state) tryPlace(ctx context.Context, st store.Store, rdName string, poo } } - s.taken[nodeKey(pool.NodeName)] = struct{}{} - delete(s.upgradeable, nodeKey(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) @@ -884,18 +875,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[nodeKey(all[i].NodeName)] = struct{}{} + excluded[naming.Key(all[i].NodeName)] = struct{}{} continue } if pattern != nil && pattern.MatchString(all[i].Name) { - excluded[nodeKey(all[i].NodeName)] = struct{}{} + excluded[naming.Key(all[i].NodeName)] = struct{}{} } } @@ -906,7 +897,7 @@ func (p *Placer) applyNotPlaceWith(ctx context.Context, rdName string, filter *a out := candidates[:0:0] for i := range candidates { - if _, blocked := excluded[nodeKey(candidates[i].NodeName)]; blocked { + if _, blocked := excluded[naming.Key(candidates[i].NodeName)]; blocked { continue } @@ -1148,7 +1139,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 @@ -1210,7 +1201,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 { @@ -1277,7 +1268,7 @@ func matchesPoolFilter(pool *apiv1.StoragePool, filter *apiv1.AutoSelectFilter, return false } - if _, off := disabled[nodeKey(pool.NodeName)]; off { + if _, off := disabled[naming.Key(pool.NodeName)]; off { return false } @@ -1290,15 +1281,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.ContainsFunc(filter.NodeNameList, func(n string) bool { return nodeKey(n) == nodeKey(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 } @@ -1353,7 +1344,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[nodeKey(nodes[i].Name)] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} break } @@ -1396,7 +1387,7 @@ func (p *Placer) autoplaceExcludedNodes(ctx context.Context, disabled map[string v, perr := strconv.ParseBool(raw) if perr == nil && !v { - out[nodeKey(nodes[i].Name)] = struct{}{} + out[naming.Key(nodes[i].Name)] = struct{}{} } } @@ -1411,7 +1402,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[nodeKey(list[i].Name)] = list[i].Props + out[naming.Key(list[i].Name)] = list[i].Props } return out, nil @@ -1424,7 +1415,7 @@ func topologyTuple(existing []apiv1.Resource, nodes map[string]map[string]string return nil } - return lookupKeys(nodes[nodeKey(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{} { @@ -1441,7 +1432,7 @@ func topologySeen(existing []apiv1.Resource, nodes map[string]map[string]string, k := spec for i := range existing { - value := nodes[nodeKey(existing[i].NodeName)][auxKey(k)] + value := nodes[naming.Key(existing[i].NodeName)][auxKey(k)] out[k+"="+value] = struct{}{} } } @@ -1579,7 +1570,7 @@ func partitionByExclusion(candidates []apiv1.StoragePool, nodes map[string]map[s for i := range candidates { pool := candidates[i] - nodeProps := nodes[nodeKey(pool.NodeName)] + nodeProps := nodes[naming.Key(pool.NodeName)] if matchesAnyExcludedPair(nodeProps, keys) { lastResort = append(lastResort, pool) @@ -1636,7 +1627,7 @@ func filterSamePinned(candidates []apiv1.StoragePool, nodes map[string]map[strin out := candidates[:0:0] for i := range candidates { - nodeProps := nodes[nodeKey(candidates[i].NodeName)] + nodeProps := nodes[naming.Key(candidates[i].NodeName)] if matchesAllSamePins(nodeProps, keys) { out = append(out, candidates[i]) @@ -1689,7 +1680,7 @@ func pickSameGroup(candidates []apiv1.StoragePool, nodes map[string]map[string]s for i := range candidates { pool := candidates[i] - tuple := lookupKeys(nodes[nodeKey(pool.NodeName)], keys) + tuple := lookupKeys(nodes[naming.Key(pool.NodeName)], keys) key := tupleKey(tuple) grp, ok := byKey[key] @@ -1699,7 +1690,7 @@ func pickSameGroup(candidates []apiv1.StoragePool, nodes map[string]map[string]s } grp.pools = append(grp.pools, pool) - grp.seen[nodeKey(pool.NodeName)] = struct{}{} + grp.seen[naming.Key(pool.NodeName)] = struct{}{} grp.free += pool.FreeCapacity } diff --git a/pkg/rest/advise.go b/pkg/rest/advise.go index 05d81cfd..fbd58d53 100644 --- a/pkg/rest/advise.go +++ b/pkg/rest/advise.go @@ -151,7 +151,7 @@ func pickAdvicePools(pools []apiv1.StoragePool, filter *apiv1.AutoSelectFilter, 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 c370323b..c28ac53f 100644 --- a/pkg/rest/autoplace.go +++ b/pkg/rest/autoplace.go @@ -644,7 +644,7 @@ func (s *Server) resolveAdditionalPlaceCount(ctx context.Context, rdName string, diskful := 0 for i := range existing { - if _, off := disabled[nodeKey(existing[i].NodeName)]; off { + if _, off := disabled[naming.Key(existing[i].NodeName)]; off { continue } 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 index 72d45b91..e681e505 100644 --- a/pkg/rest/name_spelling_cr_test.go +++ b/pkg/rest/name_spelling_cr_test.go @@ -109,7 +109,7 @@ func TestAFailedParentReadNeverLetsADeletePassItsSnapshots(t *testing.T) { func TestSnapshotSuccessKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { t.Parallel() - diskful := map[string]struct{}{nodeKey("My_Node"): {}, nodeKey("my_node"): {}} + 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}}, @@ -124,7 +124,7 @@ func TestSnapshotSuccessKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { func TestSnapshotSuccessMatchesAnEntryUnderTheCRName(t *testing.T) { t.Parallel() - diskful := map[string]struct{}{nodeKey("My_Node"): {}} + 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}}, @@ -161,11 +161,11 @@ func TestRestorePoolKeepsTwoNodesOfOneLowercaseApart(t *testing.T) { } byNode, _ := storPoolsByNodeFromSourceRD(ctx, st, "pvc-two") - if got := byNode[nodeKey("My_Node")]; got != "pool-upper" { + if got := byNode[naming.Key("My_Node")]; got != "pool-upper" { t.Errorf("pool for My_Node = %q, want pool-upper", got) } - if got := byNode[nodeKey("my_node")]; got != "pool-lower" { + if got := byNode[naming.Key("my_node")]; got != "pool-lower" { t.Errorf("pool for my_node = %q, want pool-lower", got) } } diff --git a/pkg/rest/node_lifecycle.go b/pkg/rest/node_lifecycle.go index 4b26353e..5204277a 100644 --- a/pkg/rest/node_lifecycle.go +++ b/pkg/rest/node_lifecycle.go @@ -659,13 +659,3 @@ func removeFlag(flag string) func([]string) []string { return out } } - -// nodeKey is the key a node takes in a set built from wire rows: its CR name, -// the way the store addresses it. A replica shows its node as it was -// registered, Node-1, a satellite may report itself as node-1 and a status -// entry may carry the CR name itself; all three are one node. Two case -// variants of a name outside RFC 1123, My_Node and my_node, hash to two CR -// names and stay two nodes. -func nodeKey(name string) string { - return naming.Key(name) -} diff --git a/pkg/rest/node_spelling_wire_test.go b/pkg/rest/node_spelling_wire_test.go index 1245c412..9368eff4 100644 --- a/pkg/rest/node_spelling_wire_test.go +++ b/pkg/rest/node_spelling_wire_test.go @@ -28,6 +28,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/placer" "github.com/cozystack/blockstor/pkg/store" ) @@ -197,7 +198,7 @@ func TestRestorePoolIsTheSourceReplicasOnAnotherNodeSpelling(t *testing.T) { t.Parallel() byNode, _ := storPoolsByNodeFromSourceRD(t.Context(), nodeSpellingStore(t), "pvc-mix") - if pool := byNode[nodeKey("node-1")]; pool != "pool-Node-1" { + if pool := byNode[naming.Key("node-1")]; pool != "pool-Node-1" { t.Errorf("pool for node-1 = %q, want pool-Node-1", pool) } } diff --git a/pkg/rest/physical_storage.go b/pkg/rest/physical_storage.go index af11bd93..8621205a 100644 --- a/pkg/rest/physical_storage.go +++ b/pkg/rest/physical_storage.go @@ -443,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 naming.Same(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 8cb06ee3..75b7d48d 100644 --- a/pkg/rest/query_size_info.go +++ b/pkg/rest/query_size_info.go @@ -184,7 +184,7 @@ func collectCandidates(pools []apiv1.StoragePool, disabled map[string]struct{}, continue } - if filter != nil && filter.StoragePool != "" && pool.StoragePoolName != filter.StoragePool { + if filter != nil && filter.StoragePool != "" && !naming.Same(pool.StoragePoolName, filter.StoragePool) { continue } 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_definitions.go b/pkg/rest/resource_definitions.go index e4a3afe6..cb74cc73 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" ) @@ -61,7 +62,7 @@ func (s *Server) handleRDList(w http.ResponseWriter, r *http.Request) { filtered := rds[:0] for i := range rds { - if matchAnyFold(nameFilter, rds[i].Name) { + if matchAnyName(nameFilter, rds[i].Name) { filtered = append(filtered, rds[i]) } } @@ -594,7 +595,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 } 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 9888af23..60b513a3 100644 --- a/pkg/rest/resource_groups.go +++ b/pkg/rest/resource_groups.go @@ -31,6 +31,7 @@ import ( "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" @@ -85,7 +86,7 @@ func (s *Server) handleRGList(w http.ResponseWriter, r *http.Request) { filtered := rgs[:0] for i := range rgs { - if matchAnyFold(nameFilter, rgs[i].Name) { + if matchAnyName(nameFilter, rgs[i].Name) { filtered = append(filtered, rgs[i]) } } @@ -374,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++ } } diff --git a/pkg/rest/resources.go b/pkg/rest/resources.go index b6ce6034..bfa96e24 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 @@ -69,19 +70,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 +396,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 +413,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 +454,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 +521,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 +535,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 +709,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 83f5b691..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[nodeKey(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[nodeKey(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 f24ba2b5..4e848365 100644 --- a/pkg/rest/snapshots.go +++ b/pkg/rest/snapshots.go @@ -178,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 } @@ -368,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 { @@ -376,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) { @@ -447,11 +447,11 @@ func isSnapshotSuccessful(snap *apiv1.Snapshot, diskful map[string]struct{}) boo continue } - reported[nodeKey(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 } } @@ -471,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[nodeKey(node)]; ok { - out[nodeKey(node)] = struct{}{} + if _, ok := diskful[naming.Key(node)]; ok { + out[naming.Key(node)] = struct{}{} } } @@ -517,7 +517,7 @@ func (s *Server) diskfulPeerSet(ctx context.Context, rdName string) (map[string] continue } - out[nodeKey(resList[i].NodeName)] = struct{}{} + out[naming.Key(resList[i].NodeName)] = struct{}{} } return out, nil @@ -730,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/naming.Key() 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()) 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 cbee981e..ebf74352 100644 --- a/pkg/rest/storage_pools.go +++ b/pkg/rest/storage_pools.go @@ -378,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)) @@ -395,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") } } @@ -434,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 @@ -482,11 +482,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..525990b4 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 @@ -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/controllers/metadata_created_backfill.go b/pkg/satellite/controllers/metadata_created_backfill.go index b9c1f600..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 !isNode(res.Spec.NodeName, b.NodeName) { + if !naming.Same(res.Spec.NodeName, b.NodeName) { return false } diff --git a/pkg/satellite/controllers/node_name.go b/pkg/satellite/controllers/node_name.go deleted file mode 100644 index 8f9de1a0..00000000 --- a/pkg/satellite/controllers/node_name.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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" - - "github.com/cozystack/blockstor/pkg/naming" -) - -// isNode reports whether name, as a replica or a pool spells its node, is -// node. The store addresses a Node under its folded name, so a Node -// registered as Node-1 and a satellite started as node-1 are the same -// node. A storage pool keeps the node spelling it was created with, and a -// replica written or adopted before the store and linstor-migrate stored -// the node's CR name may carry Node-1: either is this satellite's. -func isNode(name, node string) bool { - return naming.Same(name, node) -} - -// listsNode reports whether a snapshot's node list, which carries the -// registered spellings, names node. -func listsNode(nodes []string, node string) bool { - return slices.ContainsFunc(nodes, func(n string) bool { return isNode(n, node) }) -} diff --git a/pkg/satellite/controllers/physicaldevice.go b/pkg/satellite/controllers/physicaldevice.go index a117d5c3..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 && isNode(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 && - isNode(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 f65ba37d..44669360 100644 --- a/pkg/satellite/controllers/physicaldevice_discovery.go +++ b/pkg/satellite/controllers/physicaldevice_discovery.go @@ -808,7 +808,7 @@ func (p *PhysicalDeviceDiscoveryRunnable) attachedKNamesForNode(ctx context.Cont for i := range pools.Items { pool := &pools.Items[i] - if !isNode(pool.Spec.NodeName, p.NodeName) { + if !naming.Same(pool.Spec.NodeName, p.NodeName) { continue } diff --git a/pkg/satellite/controllers/resource.go b/pkg/satellite/controllers/resource.go index f6786e3f..ae198ba4 100644 --- a/pkg/satellite/controllers/resource.go +++ b/pkg/satellite/controllers/resource.go @@ -81,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 } @@ -89,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 } @@ -163,7 +163,7 @@ func (r *ResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, errors.Wrap(err, "get Resource") } - if !isNode(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 @@ -422,7 +422,7 @@ func (r *ResourceReconciler) enqueueResourcesForRD(ctx context.Context, obj clie for i := range localList.Items { local := &localList.Items[i] if naming.Same(local.Spec.ResourceDefinitionName, rd.Name) && - isNode(local.Spec.NodeName, r.Config.NodeName) { + naming.Same(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -1055,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 isNode(res.Spec.NodeName, r.Config.NodeName) { + if naming.Same(res.Spec.NodeName, r.Config.NodeName) { return nil } @@ -1069,7 +1069,7 @@ func (r *ResourceReconciler) enqueueLocalSiblings(ctx context.Context, obj clien for i := range localList.Items { local := &localList.Items[i] if naming.Same(local.Spec.ResourceDefinitionName, res.Spec.ResourceDefinitionName) && - isNode(local.Spec.NodeName, r.Config.NodeName) { + naming.Same(local.Spec.NodeName, r.Config.NodeName) { return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: local.Name}}} } } @@ -1260,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 } @@ -1635,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 isNode(r.Spec.NodeName, nodeName) + return naming.Same(r.Spec.NodeName, nodeName) } if sp, ok := obj.(*blockstoriov1alpha1.StoragePool); ok { - return isNode(sp.Spec.NodeName, nodeName) + return naming.Same(sp.Spec.NodeName, nodeName) } return false diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index c1c68a53..adbb39fa 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -88,7 +88,7 @@ func (r *SnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, errors.Wrap(err, "get Snapshot") } - if !listsNode(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 } @@ -359,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 !isNode(entries[i].NodeName, nodeName) { + if !naming.Same(entries[i].NodeName, nodeName) { continue } @@ -529,7 +529,7 @@ func upsertPerNodeStatusInPlace( mutate func(*blockstoriov1alpha1.SnapshotPerNodeStatus) bool, ) bool { for i := range *entries { - if !isNode((*entries)[i].NodeName, nodeName) { + if !naming.Same((*entries)[i].NodeName, nodeName) { continue } @@ -626,7 +626,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 !isNode(entries[i].NodeName, nodeName) { + if !naming.Same(entries[i].NodeName, nodeName) { continue } @@ -649,7 +649,7 @@ func upsertPerNodeStatus(entries []blockstoriov1alpha1.SnapshotPerNodeStatus, no replaced := false for i := range entries { - if isNode(entries[i].NodeName, nodeName) { + if naming.Same(entries[i].NodeName, nodeName) { merged := entries[i] merged.NodeName = nodeName merged.Ready = true @@ -738,7 +738,7 @@ func snapshotNodePredicate(nodeName string) predicate.Predicate { return false } - return listsNode(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 37b6ed1d..31007ac0 100644 --- a/pkg/satellite/controllers/snapshot_fetcher.go +++ b/pkg/satellite/controllers/snapshot_fetcher.go @@ -94,7 +94,7 @@ func (f *SnapshotFetcher) Fetch(ctx context.Context, srcRD, snapName string, vol var lastErr error for _, peer := range snap.Spec.Nodes { - if isNode(peer, f.NodeName) { + if naming.Same(peer, f.NodeName) { continue } diff --git a/pkg/satellite/controllers/snapshot_node_spelling_test.go b/pkg/satellite/controllers/snapshot_node_spelling_test.go index b0e46548..c36b284c 100644 --- a/pkg/satellite/controllers/snapshot_node_spelling_test.go +++ b/pkg/satellite/controllers/snapshot_node_spelling_test.go @@ -19,6 +19,7 @@ limitations under the License. package controllers import ( + "slices" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,6 +28,7 @@ import ( "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 @@ -47,8 +49,8 @@ func TestSatelliteTakesASnapshotListingItsNodeInAnotherSpelling(t *testing.T) { t.Errorf("predicate for Nodes [%s] = %v, want %v", spelling, got, want) } - if got := listsNode(snap.Spec.Nodes, "node-1"); got != want { - t.Errorf("listsNode([%s], node-1) = %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) } } } diff --git a/pkg/satellite/controllers/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index bcc1630c..0fc7ca97 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -380,7 +380,7 @@ func (s *StorageOrphanSweeperRunnable) listOwnedVolumeRefs(ctx context.Context) for i := range resList.Items { r := &resList.Items[i] - if !isNode(r.Spec.NodeName, s.NodeName) { + if !naming.Same(r.Spec.NodeName, s.NodeName) { continue } diff --git a/pkg/satellite/controllers/storagepool.go b/pkg/satellite/controllers/storagepool.go index 2523db53..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 !isNode(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 9657d71b..6be1efc3 100644 --- a/pkg/satellite/controllers/sweeper.go +++ b/pkg/satellite/controllers/sweeper.go @@ -386,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 } @@ -398,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 } @@ -602,7 +602,7 @@ func (s *OrphanSweeperRunnable) listOwnedResourceNames(ctx context.Context) (map for i := range list.Items { r := &list.Items[i] - if !isNode(r.Spec.NodeName, s.NodeName) { + if !naming.Same(r.Spec.NodeName, s.NodeName) { continue } @@ -645,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/store/cascade.go b/pkg/store/cascade.go index c0bd32e2..61fe1830 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 } @@ -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_name_key_test.go b/pkg/store/inmemory_name_key_test.go new file mode 100644 index 00000000..78f9209c --- /dev/null +++ b/pkg/store/inmemory_name_key_test.go @@ -0,0 +1,65 @@ +// 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" + + 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) + } +} diff --git a/pkg/store/inmemory_resource.go b/pkg/store/inmemory_resource.go index 3869fd0b..5accad38 100644 --- a/pkg/store/inmemory_resource.go +++ b/pkg/store/inmemory_resource.go @@ -27,6 +27,7 @@ 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 @@ -69,7 +70,7 @@ func (s *inMemoryResources) ListByNode(_ context.Context, node string) ([]apiv1. out := make([]apiv1.Resource, 0) for k := range s.m { - if FoldName(s.m[k].NodeName) == FoldName(node) { + if naming.Key(s.m[k].NodeName) == naming.Key(node) { out = append(out, s.m[k]) } } @@ -86,7 +87,7 @@ func (s *inMemoryResources) ListByDefinition(_ context.Context, rdName string) ( out := make([]apiv1.Resource, 0) for k := range s.m { - if FoldName(s.m[k].Name) == FoldName(rdName) { + if naming.Key(s.m[k].Name) == naming.Key(rdName) { out = append(out, s.m[k]) } } diff --git a/pkg/store/inmemory_snapshot.go b/pkg/store/inmemory_snapshot.go index 3aeb18ca..9d0033c2 100644 --- a/pkg/store/inmemory_snapshot.go +++ b/pkg/store/inmemory_snapshot.go @@ -27,6 +27,7 @@ import ( "github.com/cockroachdb/errors" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" + "github.com/cozystack/blockstor/pkg/naming" ) type snapKey struct { @@ -66,7 +67,7 @@ func (s *inMemorySnapshots) ListByDefinition(_ context.Context, rdName string) ( out := make([]apiv1.Snapshot, 0) for k := range s.m { - if FoldName(s.m[k].ResourceName) == FoldName(rdName) { + if naming.Key(s.m[k].ResourceName) == naming.Key(rdName) { out = append(out, s.m[k]) } } diff --git a/pkg/store/inmemory_volume_definition.go b/pkg/store/inmemory_volume_definition.go index 88627759..6787b3b1 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]) } } @@ -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]) } 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/spellings.go b/pkg/store/k8s/spellings.go index 23e2ef4f..d4a0de0a 100644 --- a/pkg/store/k8s/spellings.go +++ b/pkg/store/k8s/spellings.go @@ -69,17 +69,6 @@ const ( AnnotationLinstorNodeName = "blockstor.io/linstor-node-name" ) -// SameName reports whether two spellings of a LINSTOR name, as a replica's -// spec, a snapshot's node list, a storage pool, the wire or an operator -// spell it, address the same object: whether they map to one CR name, the -// way the store addresses the object. That covers Node-1 against node-1 and -// a CR name against the spelling it was registered under. Two case variants -// of a name outside RFC 1123, such as My_Res and my_res, hash to two CR -// names and are two objects, so they are not the same name here either. -func SameName(a, b string) bool { - return Name(a) == Name(b) -} - // 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 diff --git a/pkg/store/k8s/storage_pools.go b/pkg/store/k8s/storage_pools.go index d91ac523..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" ) @@ -80,7 +81,7 @@ func crdName(node, pool string) string { // 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 && SameName(crd.Spec.NodeName, node) + return crd.Spec.PoolName == pool && naming.Same(crd.Spec.NodeName, node) } // List returns every StoragePool CRD as a wire-shape value, sorted by 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 e26536e9..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,42 +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. -// -// The Kubernetes store now writes the folded side: a replica or snapshot it -// creates stores its parents' CR names in spec and keeps the registered -// spelling in an annotation that only the wire conversion reads, so clients -// still see the spelling they compare (pkg/store/k8s/spellings.go). The -// listings also ask under the registered spelling of a definition, so the -// shapes left are rows written before that, or adopted, in a fourth case. -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 @@ -344,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 67143753..a6df0320 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" ) @@ -377,7 +378,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 { @@ -1192,6 +1193,10 @@ 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("ListEmpty", func(t *testing.T) { got, err := newStore(t).ResourceDefinitions().List(t.Context()) if err != nil { @@ -2273,6 +2278,33 @@ func testReferencesOnNodeUnderAnotherSpelling(t *testing.T, newStore Factory) { } } +// 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() 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 } From 40a29a81c7fb1902b9e76f5b0b6ac2786741a75a Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:44:18 +0100 Subject: [PATCH 29/51] test(naming): fail on any name compared or keyed outside naming A test walks the Go sources under pkg, internal and cmd and fails on case folding applied to an object name, on comparing a reference field or variable (a replica's node or definition, a definition's group, a pool, a snapshot's nodes) with another name as it is, on slices.Contains over one, and on a map keyed by one, unless naming.Key or naming.Same is on the path. Store backends, the LINSTOR dump converter and the kernel side are out of reach, each with its reason; the few sites that match and are right as they are carry theirs in the allowlist, which also fails when an entry goes stale. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/naming/guard_test.go | 457 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 pkg/naming/guard_test.go diff --git a/pkg/naming/guard_test.go b/pkg/naming/guard_test.go new file mode 100644 index 00000000..41b021b3 --- /dev/null +++ b/pkg/naming/guard_test.go @@ -0,0 +1,457 @@ +// 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 ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// refField is a field that names another object: a replica's definition and +// node, a definition's group, a pool's node, a snapshot's definition and nodes. +// The object it names may be registered, and other rows may store it, in +// another spelling, so these fields are where spellings meet. +var refField = regexp.MustCompile(`^(NodeName|ResourceName|ResourceDefinitionName|RscName|ResourceGroupName|StoragePoolName|PoolName|Nodes|NodeNameList|SnapshotName|PeerName)$`) + +// refVar is a variable that carries such a reference, as the code names one. +var refVar = regexp.MustCompile(`^(node|nodeName|rd|rdName|rgName|pool|poolName|snapName|peer|peerName|removedNode|takeoverNode|targetNode|srcRD|srcNode|wireName|selfNode)$`) + +// objectBase is the leaf of a value whose own Name is a node, definition, +// group, pool or snapshot name: nodes[i].Name, rd.Name, groups[i].Name. +var objectBase = regexp.MustCompile(`(?i)^(nodes?|n|node\w*|rds?|def|defs|definitions?|rgs?|groups?|resourcegroups?|pools?|snaps?|snapshots?|peers?)$`) + +// objectName is any expression that holds a LINSTOR object name, for the +// fold rule: a reference, or the Name of an object. +var objectName = regexp.MustCompile(`(?i)^(node|nodes|nodename|rd|rdname|rg|rgname|resourcename|resourcedefinitionname|rscname|resourcegroupname|groupname|pool|poolname|storagepoolname|snap|snapname|snapshotname|peer|peername|name|wanted|want|candidate|needle|.*marker)$`) + +// leaf names the last identifier an expression ends in: x for x, Sel for +// a.b.Sel, the indexed value for v[i], the function for f(...). +func leaf(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + return e.Sel.Name + case *ast.IndexExpr: + return leaf(e.X) + case *ast.StarExpr: + return leaf(e.X) + case *ast.ParenExpr: + return leaf(e.X) + case *ast.CallExpr: + return leaf(e.Fun) + } + + return "" +} + +// isRef reports whether expr reads a reference: a refField, or a refVar. +func isRef(expr ast.Expr) bool { + switch e := expr.(type) { + case *ast.SelectorExpr: + if e.Sel.Name == "Name" { + return objectBase.MatchString(leaf(e.X)) + } + + return refField.MatchString(e.Sel.Name) + case *ast.Ident: + return refVar.MatchString(e.Name) + case *ast.ParenExpr: + return isRef(e.X) + case *ast.BinaryExpr: + return e.Op == token.ADD && (isRef(e.X) || isRef(e.Y)) + } + + return false +} + +// isObjectName reports whether expr may hold an object name, for the fold +// rule. +func isObjectName(expr ast.Expr) bool { + if bin, ok := expr.(*ast.BinaryExpr); ok && bin.Op == token.ADD { + return isObjectName(bin.X) || isObjectName(bin.Y) + } + + return isRef(expr) || objectName.MatchString(leaf(expr)) +} + +// keyedVars holds, for the function being walked, the variables assigned +// from naming.Key: they hold a key already. +type keyedVars map[string]bool + +// collectKeyed records every variable fn assigns from a naming.Key call. +func collectKeyed(fn ast.Node) keyedVars { + out := keyedVars{} + + ast.Inspect(fn, func(n ast.Node) bool { + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != len(assign.Rhs) { + return true + } + + for i, lhs := range assign.Lhs { + if id, ok := lhs.(*ast.Ident); ok && isKeyed(assign.Rhs[i], nil) { + out[id.Name] = true + } + } + + return true + }) + + return out +} + +// isKeyed reports whether expr is already a key: a call to naming.Key, a +// variable assigned from one, nil, or a string literal. +func isKeyed(expr ast.Expr, keyed keyedVars) bool { + switch e := expr.(type) { + case *ast.BasicLit: + return true + case *ast.Ident: + return e.Name == "nil" || keyed[e.Name] + case *ast.ParenExpr: + return isKeyed(e.X, keyed) + case *ast.CallExpr: + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "naming" && sel.Sel.Name == "Key" { + return true + } + } + } + + return false +} + +// sameShape reports whether two operands read the same field of two +// elements of one collection, as a sort comparator does: out[i].X and +// out[j].X hold names of one source, and comparing them is not mixing +// spellings. +func sameShape(a, b ast.Expr) bool { + sa, oka := a.(*ast.SelectorExpr) + sb, okb := b.(*ast.SelectorExpr) + + if !oka || !okb || sa.Sel.Name != sb.Sel.Name { + return false + } + + if pa, ok := sa.X.(*ast.Ident); ok { + if pb, ok := sb.X.(*ast.Ident); ok && isComparatorPair(pa.Name, pb.Name) { + return true + } + } + + ia, oka := sa.X.(*ast.IndexExpr) + ib, okb := sb.X.(*ast.IndexExpr) + + return oka && okb && fmt.Sprint(ia.X) == fmt.Sprint(ib.X) +} + +// isComparatorPair reports whether two names are the parameter pair a sort +// comparator is written with. +func isComparatorPair(a, b string) bool { + switch a + "," + b { + case "a,b", "left,right", "x,y", "lhs,rhs": + return true + } + + return false +} + +func callee(call *ast.CallExpr) string { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok { + return pkg.Name + "." + sel.Sel.Name + } + } + + return "" +} + +// violations walks the Go sources under the repository's pkg, internal and +// cmd trees and returns every place that folds case on a name, compares two +// name spellings as they are, or indexes a map by one, keyed +// "path: code". +func violations(t *testing.T, root string) []string { + t.Helper() + + var out []string + + fset := token.NewFileSet() + + for _, dir := range []string{"pkg", "internal", "cmd"} { + if _, err := os.Stat(filepath.Join(root, dir)); os.IsNotExist(err) { + continue + } + + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + + if d.IsDir() { + if excludedDir(rel) { + return filepath.SkipDir + } + + return nil + } + + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || strings.HasPrefix(d.Name(), "zz_generated") { + return nil + } + + if _, skip := excludedFiles()[rel]; skip { + return nil + } + + src, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", rel, err) + } + + file, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + return fmt.Errorf("parse %s: %w", rel, err) + } + + code := func(n ast.Node) string { + return strings.Join(strings.Fields(string(src[fset.Position(n.Pos()).Offset:fset.Position(n.End()).Offset])), " ") + } + + var keyed keyedVars + + ast.Inspect(file, func(n ast.Node) bool { + switch e := n.(type) { + case *ast.FuncDecl: + keyed = collectKeyed(e) + case *ast.CallExpr: + switch callee(e) { + case "slices.Contains", "slices.Index": + if len(e.Args) == 2 && isRef(e.Args[1]) && !isKeyed(e.Args[1], keyed) { + out = append(out, rel+": "+code(e)) + } + case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": + for _, arg := range e.Args { + if isObjectName(arg) { + out = append(out, rel+": "+code(e)) + + break + } + } + } + case *ast.BinaryExpr: + if e.Op != token.EQL && e.Op != token.NEQ { + return true + } + + if isKeyed(e.X, keyed) || isKeyed(e.Y, keyed) || !isRef(e.X) && !isRef(e.Y) || sameShape(e.X, e.Y) { + return true + } + + out = append(out, rel+": "+code(e)) + case *ast.IndexExpr: + // A key built by joining a name with literal text is an + // option or a label, written and read the same way. + if bin, ok := e.Index.(*ast.BinaryExpr); ok && bin.Op == token.ADD { + return true + } + + if isRef(e.Index) && !isKeyed(e.Index, keyed) { + out = append(out, rel+": "+code(e)) + } + } + + return true + }) + + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + } + + sort.Strings(out) + + 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", + "pkg/linstormigrate": "reads one LINSTOR database dump, whose rows name each other in the " + + "spelling LINSTOR stored, and compares them only with each other; the names it " + + "writes go through k8s.Name", + "pkg/drbd": "kernel side: compares names DRBD reports back with each other", + "third_party": "vendored code", + } +} + +// excludedFiles are single files out of reach, and why. +func excludedFiles() map[string]string { + return map[string]string{ + "pkg/store/inmemory.go": "the in-memory store backend, keyed as stored", + "pkg/store/inmemory_physicaldevice.go": "the in-memory store backend, asked in one spelling as the k8s one is", + "pkg/store/inmemory_resource_definition.go": "the in-memory store backend, keyed as stored", + "pkg/store/inmemory_resource_group.go": "the in-memory store backend, keyed as stored", + "pkg/store/inmemory_storage_pool.go": "the in-memory store backend, asked in one spelling as the k8s one is", + "pkg/store/inmemory_volume_definition.go": "the in-memory store backend, keyed as stored", + "pkg/satellite/reconciler.go": "works on the intent the dispatcher built and on the kernel; " + + "every name in it comes from that one intent", + } +} + +func excludedDir(rel string) bool { + _, ok := excludedDirs()[rel] + + return ok +} + +// allowed are reviewed sites the rule matches that compare or key names of +// one source, and why each is right as it is. +func allowed() map[string]string { + return map[string]string{ + "internal/cli/view/resource.go: res.LayerObject.Drbd.Connections[peer]": "" + + "peer ranges over the keys of the same reported connection map", + "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/satellite/controllers/observer.go: o.connCache[ev.ResourceName]": "" + + "caches keyed by the kernel's resource name, written and read from kernel events", + "pkg/satellite/controllers/observer.go: o.lifecycleCache[ev.ResourceName]": "" + + "caches keyed by the kernel's resource name, written and read from kernel events", + "pkg/satellite/controllers/observer.go: o.resCache[ev.ResourceName]": "" + + "caches keyed by the kernel's resource name, written and read from kernel events", + "pkg/satellite/controllers/observer.go: o.volCache[ev.ResourceName]": "" + + "caches keyed by the kernel's resource name, written and read from kernel events", + "pkg/satellite/controllers/observer.go: sig.PerConnection[peerName]": "" + + "keyed by the kernel's peer name, written and read from kernel events", + "pkg/satellite/controllers/physicaldevice_discovery.go: existing.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode] != p.NodeName": "" + + "keeps the label's stored spelling in step with the device row it labels", + "pkg/store/clone_snapshot.go: out.InUse[snaps[i].Name]": "" + + "keys are shown in the operator's message; filled and read from one listing", + } +} + +// 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() + + seen := map[string]bool{} + reviewed := allowed() + + for _, v := range violations(t, filepath.Join("..", "..")) { + seen[v] = true + + if _, ok := reviewed[v]; !ok { + t.Errorf("%s: compare or index object names through 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 rule finds each shape it exists for. +func TestTheGuardFindsEveryShape(t *testing.T) { + t.Parallel() + + root := t.TempDir() + dir := filepath.Join(root, "pkg", "planted") + + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + src := `package planted + +import ( + "slices" + "strings" +) + +type replica struct{ NodeName, ResourceName string } + +func fold(r replica, node string) bool { return strings.EqualFold(r.NodeName, node) } + +func raw(r replica, node string) bool { return r.NodeName == node } + +func keyed(m map[string]int, r replica) int { return m[r.ResourceName] } + +func contains(nodes []string, r replica) bool { return slices.Contains(nodes, r.NodeName) } + +func fine(m map[string]int, r replica, node string) bool { + k := naming.Key(r.NodeName) + + return naming.Same(r.NodeName, node) && m[k] > 0 && m[naming.Key(r.ResourceName)] > 0 +} +` + + if err := os.WriteFile(filepath.Join(dir, "planted.go"), []byte(src), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + got := violations(t, root) + + want := []string{ + "pkg/planted/planted.go: m[r.ResourceName]", + "pkg/planted/planted.go: r.NodeName == node", + "pkg/planted/planted.go: slices.Contains(nodes, r.NodeName)", + "pkg/planted/planted.go: strings.EqualFold(r.NodeName, node)", + } + + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("violations\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestInventory(t *testing.T) { + if os.Getenv("NAMING_INVENTORY") == "" { + t.Skip("set NAMING_INVENTORY=1 to print every hit") + } + + for _, v := range violations(t, filepath.Join("..", "..")) { + t.Log(v) + } +} From 6461b362789b19e785d5ed88cd995a44d5c1c7a3 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:55:41 +0100 Subject: [PATCH 30/51] fix(naming): address a CR built from a name through naming.Key Code that reads a definition or node by the name a row stores, or by the name a satellite was started with, passed that name straight to the API server as metadata.name. A row written in another spelling, or a satellite started as Node-1, then read nothing: the controller took a replica's definition as missing, and the heartbeat stamped no Node. These lookups now go through naming.Key, and the naming guard flags a reference used as the Name of an ObjectKey, NamespacedName or ObjectMeta. The satellite's own name is keyed only where it addresses its Node, so the name it renders into DRBD configuration is unchanged. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../controller/auto_diskful_controller.go | 4 +- ...ey_evacuation_test.go => name_key_test.go} | 23 ++++++++++ .../controller/node_heartbeat_controller.go | 3 +- internal/controller/resource_controller.go | 16 +++---- .../resourcedefinition_controller.go | 4 +- pkg/naming/guard_test.go | 42 +++++++++++++++++++ .../controllers/discovered_storage.go | 3 +- pkg/satellite/controllers/heartbeat.go | 3 +- pkg/satellite/controllers/heartbeat_test.go | 36 ++++++++++++++++ pkg/satellite/controllers/storage_sweeper.go | 4 +- pkg/satellite/controllers/sweeper.go | 2 +- 11 files changed, 122 insertions(+), 18 deletions(-) rename internal/controller/{name_key_evacuation_test.go => name_key_test.go} (87%) diff --git a/internal/controller/auto_diskful_controller.go b/internal/controller/auto_diskful_controller.go index 51fb520d..9ad07534 100644 --- a/internal/controller/auto_diskful_controller.go +++ b/internal/controller/auto_diskful_controller.go @@ -376,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 } @@ -415,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 } diff --git a/internal/controller/name_key_evacuation_test.go b/internal/controller/name_key_test.go similarity index 87% rename from internal/controller/name_key_evacuation_test.go rename to internal/controller/name_key_test.go index f97401b3..601a8ed5 100644 --- a/internal/controller/name_key_evacuation_test.go +++ b/internal/controller/name_key_test.go @@ -151,3 +151,26 @@ func TestTheDrainTargetSkipsADrainingNodeInAnySpelling(t *testing.T) { 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_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/resource_controller.go b/internal/controller/resource_controller.go index f34dde3c..1dbf359c 100644 --- a/internal/controller/resource_controller.go +++ b/internal/controller/resource_controller.go @@ -526,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 @@ -795,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 @@ -1387,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}} @@ -1466,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). @@ -1505,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 } } @@ -1689,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)) @@ -1728,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 } @@ -1970,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 } diff --git a/internal/controller/resourcedefinition_controller.go b/internal/controller/resourcedefinition_controller.go index e6d41d41..488405f5 100644 --- a/internal/controller/resourcedefinition_controller.go +++ b/internal/controller/resourcedefinition_controller.go @@ -940,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 @@ -1110,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 } diff --git a/pkg/naming/guard_test.go b/pkg/naming/guard_test.go index 41b021b3..fdb1e8c8 100644 --- a/pkg/naming/guard_test.go +++ b/pkg/naming/guard_test.go @@ -171,6 +171,22 @@ func sameShape(a, b ast.Expr) bool { return oka && okb && fmt.Sprint(ia.X) == fmt.Sprint(ib.X) } +// isCRAddress reports whether a composite literal of typ names a Kubernetes +// object by its metadata.name. +func isCRAddress(typ ast.Expr) bool { + sel, ok := typ.(*ast.SelectorExpr) + if !ok { + return false + } + + switch sel.Sel.Name { + case "ObjectKey", "NamespacedName", "ObjectMeta": + return true + } + + return false +} + // isComparatorPair reports whether two names are the parameter pair a sort // comparator is written with. func isComparatorPair(a, b string) bool { @@ -277,6 +293,24 @@ func violations(t *testing.T, root string) []string { } out = append(out, rel+": "+code(e)) + case *ast.CompositeLit: + // A CR addressed by a reference, as in + // client.ObjectKey{Name: res.Spec.NodeName}, is a CR name + // taken from a spelling. + if !isCRAddress(e.Type) { + return true + } + + for _, elt := range e.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Name" && isRef(kv.Value) && !isKeyed(kv.Value, keyed) { + out = append(out, rel+": "+code(kv)) + } + } case *ast.IndexExpr: // A key built by joining a name with literal text is an // option or a label, written and read the same way. @@ -361,6 +395,9 @@ func allowed() map[string]string { "keyed by the kernel's peer name, written and read from kernel events", "pkg/satellite/controllers/physicaldevice_discovery.go: existing.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode] != p.NodeName": "" + "keeps the label's stored spelling in step with the device row it labels", + "internal/controller/autosnapshot_controller.go: Name: rd.Name + \".\" + snapName": "" + + "the Snapshot CRD's CEL rule wants metadata.name to be spec.resourceDefinitionName + \".\" + " + + "spec.snapshotName, and both are stored keys already", "pkg/store/clone_snapshot.go: out.InUse[snaps[i].Name]": "" + "keys are shown in the operator's message; filled and read from one listing", } @@ -421,6 +458,10 @@ func keyed(m map[string]int, r replica) int { return m[r.ResourceName] } func contains(nodes []string, r replica) bool { return slices.Contains(nodes, r.NodeName) } +func addressed(r replica) client.ObjectKey { return client.ObjectKey{Name: r.NodeName} } + +func wire(r replica) api.Resource { return api.Resource{Name: r.NodeName} } + func fine(m map[string]int, r replica, node string) bool { k := naming.Key(r.NodeName) @@ -435,6 +476,7 @@ func fine(m map[string]int, r replica, node string) bool { got := violations(t, root) want := []string{ + "pkg/planted/planted.go: Name: r.NodeName", "pkg/planted/planted.go: m[r.ResourceName]", "pkg/planted/planted.go: r.NodeName == node", "pkg/planted/planted.go: slices.Contains(nodes, r.NodeName)", 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/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/storage_sweeper.go b/pkg/satellite/controllers/storage_sweeper.go index 0fc7ca97..b4ac3aaf 100644 --- a/pkg/satellite/controllers/storage_sweeper.go +++ b/pkg/satellite/controllers/storage_sweeper.go @@ -342,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") } @@ -417,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/sweeper.go b/pkg/satellite/controllers/sweeper.go index 6be1efc3..3d5ffba3 100644 --- a/pkg/satellite/controllers/sweeper.go +++ b/pkg/satellite/controllers/sweeper.go @@ -572,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") } From 0a55f4d87351280c61efe008d6efcfa094d26c24 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:58:28 +0100 Subject: [PATCH 31/51] fix(satellite,cli): own a cold-start orphan, and filter pools, by naming key The satellite's cold start keyed the replicas it owns by the name the store shows, the registered spelling, and looked kernel resources up under their own name, the definition's key. A resource of a definition registered as PVC-X read as another DRBD user's and was left up. The CLI's pool filter compared names as given. Both go through naming now, and the naming guard reads a replica's Name as the reference it is. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- cmd/satellite/coldstart_owned.go | 5 ++-- cmd/satellite/coldstart_owned_test.go | 33 +++++++++++++++++++++++++++ internal/cli/definition.go | 2 +- internal/cli/name_filter_test.go | 13 +++++++++++ pkg/naming/guard_test.go | 2 +- pkg/store/cascade.go | 4 ++-- 6 files changed, 53 insertions(+), 6 deletions(-) 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/internal/cli/definition.go b/internal/cli/definition.go index bfec060c..5aac859f 100644 --- a/internal/cli/definition.go +++ b/internal/cli/definition.go @@ -389,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/name_filter_test.go b/internal/cli/name_filter_test.go index 15e88e20..e85cf112 100644 --- a/internal/cli/name_filter_test.go +++ b/internal/cli/name_filter_test.go @@ -40,3 +40,16 @@ func TestListFiltersMatchByKey(t *testing.T) { } } } + +// 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/pkg/naming/guard_test.go b/pkg/naming/guard_test.go index fdb1e8c8..0e50728f 100644 --- a/pkg/naming/guard_test.go +++ b/pkg/naming/guard_test.go @@ -43,7 +43,7 @@ var refVar = regexp.MustCompile(`^(node|nodeName|rd|rdName|rgName|pool|poolName| // objectBase is the leaf of a value whose own Name is a node, definition, // group, pool or snapshot name: nodes[i].Name, rd.Name, groups[i].Name. -var objectBase = regexp.MustCompile(`(?i)^(nodes?|n|node\w*|rds?|def|defs|definitions?|rgs?|groups?|resourcegroups?|pools?|snaps?|snapshots?|peers?)$`) +var objectBase = regexp.MustCompile(`(?i)^(nodes?|n|node\w*|rds?|def|defs|definitions?|rgs?|groups?|resourcegroups?|pools?|snaps?|snapshots?|peers?|replicas?|resources?|reslist|sibs?|siblings?)$`) // objectName is any expression that holds a LINSTOR object name, for the // fold rule: a reference, or the Name of an object. diff --git a/pkg/store/cascade.go b/pkg/store/cascade.go index 61fe1830..c2496621 100644 --- a/pkg/store/cascade.go +++ b/pkg/store/cascade.go @@ -265,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) } From 74109352f6c5059b056dd85092149d9ea123c2d1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 21:59:34 +0100 Subject: [PATCH 32/51] docs(rest): say list filters match names as the store keys them The filter comments said "case-insensitive", which the filters still are for every name inside RFC 1123; outside it two case variants are two objects, and the filters now keep them apart. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/error_reports.go | 2 +- pkg/rest/resource_definitions.go | 3 ++- pkg/rest/resource_groups.go | 2 +- pkg/rest/resources.go | 3 ++- pkg/rest/snapshots.go | 4 ++-- pkg/rest/storage_pools.go | 3 ++- pkg/rest/upstream_parity_bug_225_229.go | 4 ++-- pkg/store/inmemory_volume_definition.go | 4 ++-- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/rest/error_reports.go b/pkg/rest/error_reports.go index 9a1d7407..76c11faa 100644 --- a/pkg/rest/error_reports.go +++ b/pkg/rest/error_reports.go @@ -188,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 diff --git a/pkg/rest/resource_definitions.go b/pkg/rest/resource_definitions.go index cb74cc73..20fdf0f1 100644 --- a/pkg/rest/resource_definitions.go +++ b/pkg/rest/resource_definitions.go @@ -55,7 +55,8 @@ 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 { diff --git a/pkg/rest/resource_groups.go b/pkg/rest/resource_groups.go index 60b513a3..56abee4c 100644 --- a/pkg/rest/resource_groups.go +++ b/pkg/rest/resource_groups.go @@ -79,7 +79,7 @@ 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 { diff --git a/pkg/rest/resources.go b/pkg/rest/resources.go index bfa96e24..008f6b4c 100644 --- a/pkg/rest/resources.go +++ b/pkg/rest/resources.go @@ -51,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") diff --git a/pkg/rest/snapshots.go b/pkg/rest/snapshots.go index 4e848365..162af9a3 100644 --- a/pkg/rest/snapshots.go +++ b/pkg/rest/snapshots.go @@ -168,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") diff --git a/pkg/rest/storage_pools.go b/pkg/rest/storage_pools.go index ebf74352..6a91bd18 100644 --- a/pkg/rest/storage_pools.go +++ b/pkg/rest/storage_pools.go @@ -470,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) { diff --git a/pkg/rest/upstream_parity_bug_225_229.go b/pkg/rest/upstream_parity_bug_225_229.go index 525990b4..6ca47b81 100644 --- a/pkg/rest/upstream_parity_bug_225_229.go +++ b/pkg/rest/upstream_parity_bug_225_229.go @@ -340,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. diff --git a/pkg/store/inmemory_volume_definition.go b/pkg/store/inmemory_volume_definition.go index 6787b3b1..9a92c62f 100644 --- a/pkg/store/inmemory_volume_definition.go +++ b/pkg/store/inmemory_volume_definition.go @@ -64,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() From b893b1ed5444da96e5ee89eb6e160ad6d6fc1d6d Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:25:14 +0100 Subject: [PATCH 33/51] fix(cli): refuse migrate-disk onto its own source in any spelling The self-migration guard compared the two node arguments as given, so `r td node-1 pvc-x --migrate-from Node-1` passed it. The store resolves both to one replica, the command marks that replica as migrating from itself, and the reconcile then prunes the source: the data loss the guard exists to refuse. The ends are compared with naming.Same. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/cli/guards_test.go | 32 ++++++++++++++++++++++++++++++++ internal/cli/resource.go | 3 ++- 2 files changed, 34 insertions(+), 1 deletion(-) 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/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) } From 3c0650f12c877ff690cc9d0bef1d2b27cd7fd617 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:25:14 +0100 Subject: [PATCH 34/51] fix(rest): find a node connection in any spelling of its nodes The list filter compared node names as given, and the pair key was built from the names as given, so `node-connection list node-1` missed a pair written for Node-1, and a write that spelled the node node-1 started a second pair beside it. Lookups now match a stored pair whose nodes are naming.Same in either order, which also finds pairs written before; a new pair keeps the caller's spellings, ordered by key. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/node_connections.go | 61 +++++++++++++++++++++------- pkg/rest/node_connections_test.go | 66 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 15 deletions(-) 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) + } +} From c4b9a55bd7591eef9fb92de67d274254c4ca3345 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:25:14 +0100 Subject: [PATCH 35/51] fix(store): address in-memory objects by naming key, as the k8s store The in-memory store kept definitions, groups, nodes, replicas, snapshots, pools and volume definitions under the name as given, while the Kubernetes store resolves any spelling to one CR. A test over the in-memory store could pass code that hands a non-canonical spelling to Get or Create. It now keys every map by naming.Key, and conformance cases pin the shared behaviour: an object created as pvc-x is found as Pvc-X and refuses a second create as Pvc-X, on both backends. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/inmemory.go | 45 +++++++------ pkg/store/inmemory_resource.go | 18 ++--- pkg/store/inmemory_resource_definition.go | 19 +++--- pkg/store/inmemory_resource_group.go | 19 +++--- pkg/store/inmemory_snapshot.go | 10 +-- pkg/store/inmemory_storage_pool.go | 15 +++-- pkg/store/inmemory_volume_definition.go | 14 ++-- pkg/store/storetest/storetest.go | 81 +++++++++++++++++++++++ 8 files changed, 153 insertions(+), 68 deletions(-) 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_resource.go b/pkg/store/inmemory_resource.go index 5accad38..59a4e6c6 100644 --- a/pkg/store/inmemory_resource.go +++ b/pkg/store/inmemory_resource.go @@ -101,7 +101,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[rKey{naming.Key(rdName), naming.Key(node)}] if !ok { return apiv1.Resource{}, errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -117,7 +117,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 := rKey{naming.Key(r.Name), naming.Key(r.NodeName)} if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "resource %q on node %q", r.Name, r.NodeName) } @@ -135,7 +135,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 := rKey{naming.Key(r.Name), naming.Key(r.NodeName)} prev, exists := s.m[key] if !exists { @@ -163,7 +163,7 @@ func (s *inMemoryResources) PatchResourceSpec(_ context.Context, rdName, node st s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := rKey{naming.Key(rdName), naming.Key(node)} r, ok := s.m[key] if !ok { @@ -194,7 +194,7 @@ func (s *inMemoryResources) Delete(_ context.Context, rdName, node string) error s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := rKey{naming.Key(rdName), naming.Key(node)} if _, exists := s.m[key]; !exists { return errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -217,7 +217,7 @@ func (s *inMemoryResources) DeleteIfTieBreaker(_ context.Context, rdName, node s s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := rKey{naming.Key(rdName), naming.Key(node)} r, exists := s.m[key] if !exists { @@ -247,7 +247,7 @@ func (s *inMemoryResources) SetState(_ context.Context, rdName, node string, sta s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := rKey{naming.Key(rdName), naming.Key(node)} existing, exists := s.m[key] if !exists { @@ -285,7 +285,7 @@ func (s *inMemoryResources) ClearDRBDPort(_ context.Context, rdName, node string s.mu.Lock() defer s.mu.Unlock() - key := rKey{rdName, node} + key := rKey{naming.Key(rdName), naming.Key(node)} existing, ok := s.m[key] if !ok { @@ -314,7 +314,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[rKey{naming.Key(rdName), naming.Key(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 9d0033c2..4943c6af 100644 --- a/pkg/store/inmemory_snapshot.go +++ b/pkg/store/inmemory_snapshot.go @@ -87,7 +87,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[snapKey{naming.Key(rdName), naming.Key(snapName)}] if !ok { return apiv1.Snapshot{}, errors.Wrapf(ErrNotFound, "snapshot %q on resource definition %q", snapName, rdName) } @@ -103,7 +103,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 := snapKey{naming.Key(snap.ResourceName), naming.Key(snap.Name)} if _, exists := s.m[k]; exists { return errors.Wrapf(ErrAlreadyExists, "snapshot %q on resource definition %q", snap.Name, snap.ResourceName) } @@ -121,7 +121,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 := snapKey{naming.Key(snap.ResourceName), naming.Key(snap.Name)} prev, exists := s.m[key] if !exists { @@ -145,7 +145,7 @@ func (s *inMemorySnapshots) PatchSnapshotProps( s.mu.Lock() defer s.mu.Unlock() - key := snapKey{rdName, snapName} + key := snapKey{naming.Key(rdName), naming.Key(snapName)} snap, ok := s.m[key] if !ok { @@ -178,7 +178,7 @@ func (s *inMemorySnapshots) Delete(_ context.Context, rdName, snapName string) e s.mu.Lock() defer s.mu.Unlock() - k := snapKey{rdName, snapName} + k := snapKey{naming.Key(rdName), naming.Key(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 9a92c62f..f5e0adc7 100644 --- a/pkg/store/inmemory_volume_definition.go +++ b/pkg/store/inmemory_volume_definition.go @@ -89,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) } @@ -105,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) } @@ -137,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 } } @@ -147,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 } @@ -160,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) } @@ -182,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 { @@ -208,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/storetest/storetest.go b/pkg/store/storetest/storetest.go index a6df0320..266ba140 100644 --- a/pkg/store/storetest/storetest.go +++ b/pkg/store/storetest/storetest.go @@ -42,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) }) @@ -685,6 +686,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 { @@ -1197,6 +1199,9 @@ func RunResourceDefinitionStore(t *testing.T, newStore Factory) { 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 { @@ -2278,6 +2283,82 @@ func testReferencesOnNodeUnderAnotherSpelling(t *testing.T, newStore Factory) { } } +// 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) + } +} + +// 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. From 58c316be8925d7b41bd038a37b74c5cdbda10796 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:25:14 +0100 Subject: [PATCH 36/51] fix(naming): keep Key idempotent for a name with no letter or digit Such a name keyed to its hash followed by a dash, which is not a name the API server takes and which Key hashed a second time. The dash is left out when there is no slug. No stored object can carry the old form, so no existing name changes. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/naming/naming.go | 13 +++++++++---- pkg/naming/naming_test.go | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/naming/naming.go b/pkg/naming/naming.go index 7937afe3..bf5d1328 100644 --- a/pkg/naming/naming.go +++ b/pkg/naming/naming.go @@ -87,12 +87,17 @@ func Key(in string) string { slug := strings.Trim(builder.String(), "-") digest := sha256.Sum256([]byte(in)) - out := hex.EncodeToString(digest[:hashPrefixBytes]) + "-" + slug + 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 { - // A cut that ends on a dash is not a name the API server takes, - // and Key of it would hash it again; trimming keeps Key - // idempotent on every key it returns. out = strings.TrimRight(out[:maxName], "-") } diff --git a/pkg/naming/naming_test.go b/pkg/naming/naming_test.go index c8f57e32..262dcc43 100644 --- a/pkg/naming/naming_test.go +++ b/pkg/naming/naming_test.go @@ -60,7 +60,7 @@ func TestKeyIsIdempotent(t *testing.T) { t.Parallel() for _, in := range []string{ - "pvc-1", "Node-1", "My_Res", "my_res", "a.b_C", + "pvc-1", "Node-1", "My_Res", "my_res", "a.b_C", "_", "--", "", strings.Repeat("ab_", 100), strings.Repeat("a", 243) + "_" + strings.Repeat("b", 20), } { From 506b979012481b0bb67087d7ba0b7ba06125f8d6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:45:24 +0100 Subject: [PATCH 37/51] fix(placer): find an existing replica's pool in any node spelling The placer looked an existing replica's pool up by the node as the replica shows it, in a map filled by the node as the pool listing shows it. When the two spellings differed the pool was not found, so a new replica could land on a pool sharing the existing replica's LUN, and the provider-kind constraint started from nothing. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/placer/name_key_internal_test.go | 22 ++++++++++++++++++++++ pkg/placer/placer.go | 20 ++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/pkg/placer/name_key_internal_test.go b/pkg/placer/name_key_internal_test.go index a9d6da1b..86b5c12f 100644 --- a/pkg/placer/name_key_internal_test.go +++ b/pkg/placer/name_key_internal_test.go @@ -58,3 +58,25 @@ func TestMinRscCountCountsReplicasInAnySpellingOfThePoolsNode(t *testing.T) { 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 2496a8c5..f4654b15 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -542,6 +542,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 @@ -549,13 +551,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[naming.Key(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[naming.Key(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) @@ -563,7 +565,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[naming.Key(existing[i].NodeName)] + nodeProps := nodes[naming.Key(replica.NodeName)] for key := range filter.XReplicasOnDifferentMap { s.xBuckets[xBucketKey(key, nodeProps)]++ } @@ -571,12 +573,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 } @@ -826,9 +828,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 { From 8ab8453b0b54529dc130630bd14f1722f6ce28c8 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:45:24 +0100 Subject: [PATCH 38/51] fix(rest): keep one resource-connection path entry per node pair Paths were stored under a prop named by the two nodes as the caller spelled them, so posting a path for Node-1/beta and another for node-1/beta opened two entries for one pair, and the dispatcher handed the satellite two connections between the same two hosts. A read or delete in the other spelling missed the stored paths. The pair's prop is now found by key, and a new one is ordered by key. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/node_spelling_wire_test.go | 57 +++++++++++++++++++++++++++++ pkg/rest/resource_connections.go | 48 ++++++++++++++++++------ 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/pkg/rest/node_spelling_wire_test.go b/pkg/rest/node_spelling_wire_test.go index 9368eff4..609e196f 100644 --- a/pkg/rest/node_spelling_wire_test.go +++ b/pkg/rest/node_spelling_wire_test.go @@ -337,3 +337,60 @@ func TestNonSnapshotPoolCheckScopesTheTargetsInAnotherSpelling(t *testing.T) { 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) + } +} 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) From c6a95af888d182769f21d023a5518234878cf928 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:45:24 +0100 Subject: [PATCH 39/51] fix(rest): wait for the peer-forget ack under the removed node's key The wait asked for an annotation named after the removed node as the request spelled it, so an acknowledgment stamped for node-1 was never seen for Node-1 and the delete sat out the full timeout. An annotation key cannot carry every spelling, so the peer is named by its key. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/rest/node_spelling_wire_test.go | 32 +++++++++++++++++++++++++++++ pkg/rest/peer_delete_sync.go | 5 +++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pkg/rest/node_spelling_wire_test.go b/pkg/rest/node_spelling_wire_test.go index 609e196f..9e72cad4 100644 --- a/pkg/rest/node_spelling_wire_test.go +++ b/pkg/rest/node_spelling_wire_test.go @@ -394,3 +394,35 @@ func TestResourceConnectionPathsKeepOneEntryPerPairInAnySpelling(t *testing.T) { 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) + } +} diff --git a/pkg/rest/peer_delete_sync.go b/pkg/rest/peer_delete_sync.go index c2815a8d..9bce75a4 100644 --- a/pkg/rest/peer_delete_sync.go +++ b/pkg/rest/peer_delete_sync.go @@ -50,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. @@ -121,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 { From ffd18028218b107533f5b3624317064a9d845f96 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:45:24 +0100 Subject: [PATCH 40/51] refactor(controller): match a replica to its definition with Same Every caller passes the definition's CR name, for which this is the same comparison; it no longer depends on that. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- internal/controller/replica_of.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/controller/replica_of.go b/internal/controller/replica_of.go index f8b1d9ef..6884e07a 100644 --- a/internal/controller/replica_of.go +++ b/internal/controller/replica_of.go @@ -29,13 +29,13 @@ import ( "github.com/cozystack/blockstor/pkg/naming" ) -// isReplicaOf reports whether res belongs to the definition stored as -// rdName, the CR name. The store writes a replica with the definition's CR +// 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.Key(res.Spec.ResourceDefinitionName) == rdName + return naming.Same(res.Spec.ResourceDefinitionName, rdName) } // getReplica reads the replica of definition rd on node into out, whichever From 01e647cb06d76db14eb05a6854a5d86c657c5b1f Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:45:24 +0100 Subject: [PATCH 41/51] test(naming): follow names by provenance in the guard The guard matched object names by what variables were called, so a raw comparison under any other name went through: migrate-disk's src == dst and the node-connection filter were two. It now type-checks the tree and follows a name from where it enters, the name fields of the wire and CRD types, the path parameters, query lists and CLI flags that carry names, and whatever is passed to the store as a name, through assignments, ranges, calls and returns, to the comparison, map key, search, case fold or CR address it reaches. A planted package checks every shape under arbitrary names, and the shapes it must leave alone. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- go.mod | 2 +- pkg/naming/guard_test.go | 936 +++++++++++++++++-------- pkg/naming/testdata/planted/planted.go | 139 ++++ 3 files changed, 792 insertions(+), 285 deletions(-) create mode 100644 pkg/naming/testdata/planted/planted.go 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/pkg/naming/guard_test.go b/pkg/naming/guard_test.go index 0e50728f..6453a1f1 100644 --- a/pkg/naming/guard_test.go +++ b/pkg/naming/guard_test.go @@ -21,147 +21,535 @@ package naming_test import ( "fmt" "go/ast" - "go/parser" + "go/constant" "go/token" - "io/fs" + "go/types" "os" "path/filepath" - "regexp" + "slices" "sort" + "strconv" "strings" "testing" + + "golang.org/x/tools/go/packages" ) -// refField is a field that names another object: a replica's definition and -// node, a definition's group, a pool's node, a snapshot's definition and nodes. -// The object it names may be registered, and other rows may store it, in -// another spelling, so these fields are where spellings meet. -var refField = regexp.MustCompile(`^(NodeName|ResourceName|ResourceDefinitionName|RscName|ResourceGroupName|StoragePoolName|PoolName|Nodes|NodeNameList|SnapshotName|PeerName)$`) +// The guard follows values that hold a LINSTOR object name from where they +// enter (a source) to where they are compared, folded, used as a map key, +// searched for or used to address a CR (a sink), and fails on a sink that +// naming.Key or naming.Same is not on. It works on types and on where a +// value comes from, not on what a variable is called, so a rename does not +// hide a site from it. + +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, "name": 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 and the +// store's own types. +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, + } +} + +// namedTypes are the wire types whose own Name is an object name, in the +// spelling it was registered under. A CRD's own Name is its CR name, a key, +// and so is not a source. +func namedTypes() map[string]bool { + return map[string]bool{ + "Node": true, "ResourceDefinition": true, "ResourceGroup": true, "Resource": true, + "ResourceWithVolumes": true, "Snapshot": true, "StoragePool": true, "StoragePoolDefinition": true, + } +} + +type guard struct { + pkgs []*packages.Package + tainted map[types.Object]bool + keyed map[types.Object]bool + returns map[*types.Func]bool + decls map[*types.Func]*ast.FuncDecl + info map[*ast.FuncDecl]*types.Info +} + +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]bool{}, keyed: map[types.Object]bool{}, returns: map[*types.Func]bool{}, + decls: map[*types.Func]*ast.FuncDecl{}, info: map[*ast.FuncDecl]*types.Info{}, + } + + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + for _, d := range file.Decls { + if fn, ok := d.(*ast.FuncDecl); ok && fn.Body != nil { + if obj, ok := pkg.TypesInfo.Defs[fn.Name].(*types.Func); ok { + g.decls[obj] = fn + g.info[fn] = pkg.TypesInfo + } + } + } + } + } + + g.propagate() -// refVar is a variable that carries such a reference, as the code names one. -var refVar = regexp.MustCompile(`^(node|nodeName|rd|rdName|rgName|pool|poolName|snapName|peer|peerName|removedNode|takeoverNode|targetNode|srcRD|srcNode|wireName|selfNode)$`) + return g +} + +func isString(info *types.Info, e ast.Expr) bool { + t := info.TypeOf(e) + if t == nil { + return false + } + + b, ok := t.Underlying().(*types.Basic) -// objectBase is the leaf of a value whose own Name is a node, definition, -// group, pool or snapshot name: nodes[i].Name, rd.Name, groups[i].Name. -var objectBase = regexp.MustCompile(`(?i)^(nodes?|n|node\w*|rds?|def|defs|definitions?|rgs?|groups?|resourcegroups?|pools?|snaps?|snapshots?|peers?|replicas?|resources?|reslist|sibs?|siblings?)$`) + return ok && b.Info()&types.IsString != 0 +} -// objectName is any expression that holds a LINSTOR object name, for the -// fold rule: a reference, or the Name of an object. -var objectName = regexp.MustCompile(`(?i)^(node|nodes|nodename|rd|rdname|rg|rgname|resourcename|resourcedefinitionname|rscname|resourcegroupname|groupname|pool|poolname|storagepoolname|snap|snapname|snapshotname|peer|peername|name|wanted|want|candidate|needle|.*marker)$`) +func isConstant(info *types.Info, e ast.Expr) bool { + if id, ok := e.(*ast.Ident); ok && id.Name == "nil" { + return true + } -// leaf names the last identifier an expression ends in: x for x, Sel for -// a.b.Sel, the indexed value for v[i], the function for f(...). -func leaf(expr ast.Expr) string { - switch e := expr.(type) { + tv, ok := info.Types[e] + + return ok && tv.Value != nil +} + +// calleeOf is the function a call statically calls, if any. +func calleeOf(info *types.Info, call *ast.CallExpr) *types.Func { + switch fun := ast.Unparen(call.Fun).(type) { case *ast.Ident: - return e.Name + f, _ := info.Uses[fun].(*types.Func) + + return f case *ast.SelectorExpr: - return e.Sel.Name - case *ast.IndexExpr: - return leaf(e.X) - case *ast.StarExpr: - return leaf(e.X) - case *ast.ParenExpr: - return leaf(e.X) - case *ast.CallExpr: - return leaf(e.Fun) + if sel, ok := info.Selections[fun]; ok { + f, _ := sel.Obj().(*types.Func) + + return f + } + + f, _ := info.Uses[fun.Sel].(*types.Func) + + return f } - return "" + return nil } -// isRef reports whether expr reads a reference: a refField, or a refVar. -func isRef(expr ast.Expr) bool { - switch e := expr.(type) { - case *ast.SelectorExpr: - if e.Sel.Name == "Name" { - return objectBase.MatchString(leaf(e.X)) +func funcName(f *types.Func) string { + if f == nil || f.Pkg() == nil { + return "" + } + + if recv := f.Type().(*types.Signature).Recv(); recv != nil { + t := recv.Type() + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + + if n, ok := t.(*types.Named); ok { + return f.Pkg().Path() + "." + n.Obj().Name() + "." + f.Name() } + } + + return f.Pkg().Path() + "." + f.Name() +} + +// 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 refField.MatchString(e.Sel.Name) + 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: + return isKeyCall(info, x) case *ast.Ident: - return refVar.MatchString(e.Name) - case *ast.ParenExpr: - return isRef(e.X) - case *ast.BinaryExpr: - return e.Op == token.ADD && (isRef(e.X) || isRef(e.Y)) + obj := info.Uses[x] + + return obj != nil && g.keyed[obj] && !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 } -// isObjectName reports whether expr may hold an object name, for the fold -// rule. -func isObjectName(expr ast.Expr) bool { - if bin, ok := expr.(*ast.BinaryExpr); ok && bin.Op == token.ADD { - return isObjectName(bin.X) || isObjectName(bin.Y) +// 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 (g *guard) 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" } - return isRef(expr) || objectName.MatchString(leaf(expr)) + if !sourcePackages()[named.Obj().Pkg().Path()] { + return false + } + + name := sel.Sel.Name + if name == "Name" { + return named.Obj().Pkg().Path() == module+"/pkg/api/v1" && namedTypes()[named.Obj().Name()] + } + + return sourceFields()[name] } -// keyedVars holds, for the function being walked, the variables assigned -// from naming.Key: they hold a key already. -type keyedVars map[string]bool +// 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 { + switch x := ast.Unparen(e).(type) { + case *ast.Ident: + obj := info.Uses[x] + if obj == nil { + obj = info.Defs[x] + } + + return obj != nil && g.tainted[obj] + case *ast.SelectorExpr: + return g.isSourceField(info, x) + case *ast.IndexExpr: + return isFlagSource(info, x) || g.isTainted(info, x.X) + case *ast.SliceExpr: + return g.isTainted(info, x.X) + case *ast.StarExpr: + return g.isTainted(info, x.X) + case *ast.BinaryExpr: + return x.Op == token.ADD && (g.isTainted(info, x.X) || g.isTainted(info, x.Y)) + case *ast.CallExpr: + if isKeyCall(info, x) { + return false + } + + if isSourceCall(info, x) { + return true + } -// collectKeyed records every variable fn assigns from a naming.Key call. -func collectKeyed(fn ast.Node) keyedVars { - out := keyedVars{} + if tv, ok := info.Types[x.Fun]; ok && tv.IsType() && len(x.Args) == 1 { + return g.isTainted(info, x.Args[0]) + } - ast.Inspect(fn, func(n ast.Node) bool { - assign, ok := n.(*ast.AssignStmt) - if !ok || len(assign.Lhs) != len(assign.Rhs) { + f := calleeOf(info, x) + if f != nil && g.returns[f] { return true } - for i, lhs := range assign.Lhs { - if id, ok := lhs.(*ast.Ident); ok && isKeyed(assign.Rhs[i], nil) { - out[id.Name] = true - } + // A method of a value that holds names, such as url.Values.Get, + // returns one of them. + if sel, ok := ast.Unparen(x.Fun).(*ast.SelectorExpr); ok && f != nil && isString(info, x) { + return g.isTainted(info, sel.X) } - return true - }) + switch funcName(f) { + case "strings.TrimSpace", "strings.ToLower", "strings.ToUpper", "strings.Trim", "strings.TrimPrefix", + "strings.TrimSuffix", "strings.Split", "strings.Fields": + return len(x.Args) > 0 && g.isTainted(info, x.Args[0]) + } + } - return out + return false } -// isKeyed reports whether expr is already a key: a call to naming.Key, a -// variable assigned from one, nil, or a string literal. -func isKeyed(expr ast.Expr, keyed keyedVars) bool { - switch e := expr.(type) { - case *ast.BasicLit: - return true - case *ast.Ident: - return e.Name == "nil" || keyed[e.Name] - case *ast.ParenExpr: - return isKeyed(e.X, keyed) - case *ast.CallExpr: - if sel, ok := e.Fun.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "naming" && sel.Sel.Name == "Key" { +func (g *guard) mark(obj types.Object) bool { + if obj == nil || g.tainted[obj] { + return false + } + + g.tainted[obj] = true + + return true +} + +// propagate follows sources through assignments, ranges, calls and returns +// until nothing changes. +func (g *guard) propagate() { + for changed := true; changed; { + changed = false + + for fn, decl := range g.decls { + info := g.info[decl] + + ast.Inspect(decl.Body, func(n ast.Node) bool { + switch s := n.(type) { + 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 keys are not its values: ranging over a + // map of names yields its values only. + if g.isTainted(info, s.X) && s.Value != nil { + changed = g.markExpr(info, s.Value) || changed + } + case *ast.ReturnStmt: + if len(s.Results) > 0 && g.isTainted(info, s.Results[0]) && !g.returns[fn] { + g.returns[fn] = true + changed = true + } + case *ast.CallExpr: + changed = g.passArgs(info, s) || changed + } + return true + }) + } + } +} + +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] +} + +func (g *guard) markExpr(info *types.Info, e ast.Expr) bool { + return g.mark(identObj(info, e)) +} + +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 g.isTainted(info, rhs[i]) { + changed = g.markExpr(info, lhs[i]) || changed + } else if g.isKey(info, rhs[i]) { + changed = g.markKeyed(info, lhs[i]) || changed } } + case len(rhs) == 1 && len(lhs) > 0: + if g.isTainted(info, rhs[0]) { + changed = g.markExpr(info, lhs[0]) + } } - return false + return changed +} + +// passArgs taints the parameters a call passes names into and, the other +// way, the arguments a call passes as names: to the store, or to a +// parameter that holds names. +func (g *guard) passArgs(info *types.Info, call *ast.CallExpr) bool { + f := calleeOf(info, call) + if f == nil { + return false + } + + changed := false + + if 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) || changed + } + } + } + + decl, ok := g.decls[f] + if !ok { + return changed + } + + calleeInfo := g.info[decl] + + var params []types.Object + + for _, field := range decl.Type.Params.List { + for _, id := range field.Names { + params = append(params, calleeInfo.Defs[id]) + } + } + + for i, arg := range call.Args { + if i >= len(params) { + continue + } + + switch { + case g.isTainted(info, arg): + changed = g.mark(params[i]) || changed + case g.tainted[params[i]] && isString(info, arg) && !g.isKey(info, arg): + changed = g.markExpr(info, arg) || changed + } + } + + return changed } -// sameShape reports whether two operands read the same field of two -// elements of one collection, as a sort comparator does: out[i].X and -// out[j].X hold names of one source, and comparing them is not mixing +// sameShape reports whether two operands read one field of two elements of +// one collection, or of a comparator's two arguments: one source, not two // spellings. func sameShape(a, b ast.Expr) bool { - sa, oka := a.(*ast.SelectorExpr) - sb, okb := b.(*ast.SelectorExpr) + 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, ok := sa.X.(*ast.Ident); ok { - if pb, ok := sb.X.(*ast.Ident); ok && isComparatorPair(pa.Name, pb.Name) { - return true + if pb, ok := sb.X.(*ast.Ident); ok { + switch pa.Name + "," + pb.Name { + case "a,b", "left,right", "x,y", "lhs,rhs": + return true + } } } @@ -171,171 +559,157 @@ func sameShape(a, b ast.Expr) bool { return oka && okb && fmt.Sprint(ia.X) == fmt.Sprint(ib.X) } -// isCRAddress reports whether a composite literal of typ names a Kubernetes -// object by its metadata.name. -func isCRAddress(typ ast.Expr) bool { - sel, ok := typ.(*ast.SelectorExpr) - if !ok { +func isCRAddress(info *types.Info, lit *ast.CompositeLit) bool { + t := info.TypeOf(lit) + if t == nil { return false } - switch sel.Sel.Name { - case "ObjectKey", "NamespacedName", "ObjectMeta": - return true + // client.ObjectKey is an alias of types.NamespacedName. + named, ok := types.Unalias(t).(*types.Named) + if !ok || named.Obj().Pkg() == nil { + return false } - return false -} - -// isComparatorPair reports whether two names are the parameter pair a sort -// comparator is written with. -func isComparatorPair(a, b string) bool { - switch a + "," + b { - case "a,b", "left,right", "x,y", "lhs,rhs": + 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 } -func callee(call *ast.CallExpr) string { - if sel, ok := call.Fun.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok { - return pkg.Name + "." + sel.Sel.Name - } - } - - return "" +type finding struct { + file string + line int + code string } -// violations walks the Go sources under the repository's pkg, internal and -// cmd trees and returns every place that folds case on a name, compares two -// name spellings as they are, or indexes a map by one, keyed -// "path: code". -func violations(t *testing.T, root string) []string { - t.Helper() - - var out []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)) - fset := token.NewFileSet() + for _, f := range found { + out = append(out, f.file+": "+f.code) + } - for _, dir := range []string{"pkg", "internal", "cmd"} { - if _, err := os.Stat(filepath.Join(root, dir)); os.IsNotExist(err) { - continue - } + sort.Strings(out) - err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } + return slices.Compact(out) +} - rel, _ := filepath.Rel(root, path) - rel = filepath.ToSlash(rel) +func (g *guard) findings(root string) []finding { + var out []finding - if d.IsDir() { - if excludedDir(rel) { - return filepath.SkipDir - } + for _, pkg := range g.pkgs { + info := pkg.TypesInfo - return nil - } + for _, file := range pkg.Syntax { + path := pkg.Fset.File(file.Pos()).Name() - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || strings.HasPrefix(d.Name(), "zz_generated") { - return nil + rel, err := filepath.Rel(root, path) + if err != nil { + continue } - if _, skip := excludedFiles()[rel]; skip { - return nil + rel = filepath.ToSlash(rel) + if strings.HasSuffix(rel, "_test.go") || excluded(rel) { + continue } src, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("read %s: %w", rel, err) - } - - file, err := parser.ParseFile(fset, path, src, 0) - if err != nil { - return fmt.Errorf("parse %s: %w", rel, err) + continue } code := func(n ast.Node) string { - return strings.Join(strings.Fields(string(src[fset.Position(n.Pos()).Offset:fset.Position(n.End()).Offset])), " ") + return strings.Join(strings.Fields(string(src[pkg.Fset.Position(n.Pos()).Offset:pkg.Fset.Position(n.End()).Offset])), " ") } - var keyed keyedVars - ast.Inspect(file, func(n ast.Node) bool { - switch e := n.(type) { - case *ast.FuncDecl: - keyed = collectKeyed(e) - case *ast.CallExpr: - switch callee(e) { - case "slices.Contains", "slices.Index": - if len(e.Args) == 2 && isRef(e.Args[1]) && !isKeyed(e.Args[1], keyed) { - out = append(out, rel+": "+code(e)) - } - case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": - for _, arg := range e.Args { - if isObjectName(arg) { - out = append(out, rel+": "+code(e)) - - break - } - } - } - case *ast.BinaryExpr: - if e.Op != token.EQL && e.Op != token.NEQ { - return true - } + if hit := g.sink(info, n); hit != nil { + out = append(out, finding{file: rel, line: pkg.Fset.Position(hit.Pos()).Line, code: code(hit)}) + } - if isKeyed(e.X, keyed) || isKeyed(e.Y, keyed) || !isRef(e.X) && !isRef(e.Y) || sameShape(e.X, e.Y) { - return true - } + return true + }) + } + } - out = append(out, rel+": "+code(e)) - case *ast.CompositeLit: - // A CR addressed by a reference, as in - // client.ObjectKey{Name: res.Spec.NodeName}, is a CR name - // taken from a spelling. - if !isCRAddress(e.Type) { - return true - } + return out +} - for _, elt := range e.Elts { - kv, ok := elt.(*ast.KeyValueExpr) - if !ok { - continue - } +// 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 { + return g.isTainted(info, x) && g.nameLike(info, y) || g.isTainted(info, y) && g.nameLike(info, x) +} - if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Name" && isRef(kv.Value) && !isKeyed(kv.Value, keyed) { - out = append(out, rel+": "+code(kv)) - } - } - case *ast.IndexExpr: - // A key built by joining a name with literal text is an - // option or a label, written and read the same way. - if bin, ok := e.Index.(*ast.BinaryExpr); ok && bin.Op == token.ADD { - return true - } +// sink returns the node to report when n is a sink a source reaches. +func (g *guard) sink(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) { + return nil + } - if isRef(e.Index) && !isKeyed(e.Index, keyed) { - out = append(out, rel+": "+code(e)) - } - } + if isConstant(info, e.X) || isConstant(info, e.Y) || sameShape(e.X, e.Y) { + return nil + } - return true - }) + if g.rawPair(info, e.X, e.Y) { + return e + } + case *ast.IndexExpr: + m, ok := info.TypeOf(e.X).Underlying().(*types.Map) + if !ok { + return nil + } + if k, ok := m.Key().Underlying().(*types.Basic); !ok || k.Info()&types.IsString == 0 { return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", dir, err) } - } - sort.Strings(out) + if bin, ok := ast.Unparen(e.Index).(*ast.BinaryExpr); ok && bin.Op == token.ADD { + return nil + } - return out + if !isKeyCall(info, e.Index) && !isConstant(info, e.Index) && g.isTainted(info, e.Index) { + return e + } + case *ast.CallExpr: + switch funcName(calleeOf(info, e)) { + case "slices.Contains", "slices.Index": + if len(e.Args) == 2 && !isConstant(info, e.Args[1]) && g.rawPair(info, e.Args[0], e.Args[1]) { + return e + } + case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": + for _, arg := range e.Args { + if g.isTainted(info, arg) { + return e + } + } + } + case *ast.CompositeLit: + if !isCRAddress(info, e) { + return nil + } + + for _, elt := range e.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) { + return kv + } + } + } + + return nil } // excludedDirs are the trees the rule does not reach, and why. @@ -356,53 +730,66 @@ func excludedDirs() map[string]string { // excludedFiles are single files out of reach, and why. func excludedFiles() map[string]string { return map[string]string{ - "pkg/store/inmemory.go": "the in-memory store backend, keyed as stored", - "pkg/store/inmemory_physicaldevice.go": "the in-memory store backend, asked in one spelling as the k8s one is", - "pkg/store/inmemory_resource_definition.go": "the in-memory store backend, keyed as stored", - "pkg/store/inmemory_resource_group.go": "the in-memory store backend, keyed as stored", - "pkg/store/inmemory_storage_pool.go": "the in-memory store backend, asked in one spelling as the k8s one is", - "pkg/store/inmemory_volume_definition.go": "the in-memory store backend, keyed as stored", "pkg/satellite/reconciler.go": "works on the intent the dispatcher built and on the kernel; " + "every name in it comes from that one intent", } } -func excludedDir(rel string) bool { - _, ok := excludedDirs()[rel] +func excluded(rel string) bool { + if _, ok := excludedFiles()[rel]; ok { + return true + } + + for dir := range excludedDirs() { + if strings.HasPrefix(rel, dir+"/") { + return true + } + } - return ok + return false } -// allowed are reviewed sites the rule matches that compare or key names of -// one source, and why each is right as it is. +// 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" + return map[string]string{ - "internal/cli/view/resource.go: res.LayerObject.Drbd.Connections[peer]": "" + - "peer ranges over the keys of the same reported connection map", + "cmd/satellite/coldstart_owned.go: healthy[name]": "" + + "kernel resource names on both sides, from one drbdsetup listing", + "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/satellite/controllers/observer.go: o.connCache[ev.ResourceName]": "" + - "caches keyed by the kernel's resource name, written and read from kernel events", - "pkg/satellite/controllers/observer.go: o.lifecycleCache[ev.ResourceName]": "" + - "caches keyed by the kernel's resource name, written and read from kernel events", - "pkg/satellite/controllers/observer.go: o.resCache[ev.ResourceName]": "" + - "caches keyed by the kernel's resource name, written and read from kernel events", - "pkg/satellite/controllers/observer.go: o.volCache[ev.ResourceName]": "" + - "caches keyed by the kernel's resource name, written and read from kernel events", - "pkg/satellite/controllers/observer.go: sig.PerConnection[peerName]": "" + - "keyed by the kernel's peer name, written and read from kernel events", - "pkg/satellite/controllers/physicaldevice_discovery.go: existing.Labels[blockstoriov1alpha1.PhysicalDeviceLabelNode] != p.NodeName": "" + - "keeps the label's stored spelling in step with the device row it labels", - "internal/controller/autosnapshot_controller.go: Name: rd.Name + \".\" + snapName": "" + - "the Snapshot CRD's CEL rule wants metadata.name to be spec.resourceDefinitionName + \".\" + " + - "spec.snapshotName, and both are stored keys already", + "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: 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 @@ -412,14 +799,15 @@ func allowed() map[string]string { func TestNamesAreComparedAsKeys(t *testing.T) { t.Parallel() - seen := map[string]bool{} + root := moduleRoot(t) reviewed := allowed() + seen := map[string]bool{} - for _, v := range violations(t, filepath.Join("..", "..")) { + for _, v := range newGuard(t, root, "./...").violations(root) { seen[v] = true if _, ok := reviewed[v]; !ok { - t.Errorf("%s: compare or index object names through naming.Key or naming.Same", v) + t.Errorf("%s: compare or key the name with naming.Key or naming.Same", v) } } @@ -430,61 +818,39 @@ func TestNamesAreComparedAsKeys(t *testing.T) { } } -// The rule finds each shape it exists for. -func TestTheGuardFindsEveryShape(t *testing.T) { +// 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() - root := t.TempDir() - dir := filepath.Join(root, "pkg", "planted") + dir := filepath.Join(moduleRoot(t), "pkg", "naming", "testdata", "planted") - if err := os.MkdirAll(dir, 0o750); err != nil { - t.Fatalf("mkdir: %v", err) + src, err := os.ReadFile(filepath.Join(dir, "planted.go")) + if err != nil { + t.Fatal(err) } - src := `package planted + var want []string -import ( - "slices" - "strings" -) - -type replica struct{ NodeName, ResourceName string } - -func fold(r replica, node string) bool { return strings.EqualFold(r.NodeName, node) } - -func raw(r replica, node string) bool { return r.NodeName == node } - -func keyed(m map[string]int, r replica) int { return m[r.ResourceName] } - -func contains(nodes []string, r replica) bool { return slices.Contains(nodes, r.NodeName) } - -func addressed(r replica) client.ObjectKey { return client.ObjectKey{Name: r.NodeName} } - -func wire(r replica) api.Resource { return api.Resource{Name: r.NodeName} } - -func fine(m map[string]int, r replica, node string) bool { - k := naming.Key(r.NodeName) - - return naming.Same(r.NodeName, node) && m[k] > 0 && m[naming.Key(r.ResourceName)] > 0 -} -` - - if err := os.WriteFile(filepath.Join(dir, "planted.go"), []byte(src), 0o600); err != nil { - t.Fatalf("write: %v", err) + for i, line := range strings.Split(string(src), "\n") { + if strings.HasSuffix(line, "// want") { + want = append(want, strconv.Itoa(i+1)) + } } - got := violations(t, root) + found := newGuard(t, dir, ".").findings(dir) + got := make([]string, 0, len(found)) - want := []string{ - "pkg/planted/planted.go: Name: r.NodeName", - "pkg/planted/planted.go: m[r.ResourceName]", - "pkg/planted/planted.go: r.NodeName == node", - "pkg/planted/planted.go: slices.Contains(nodes, r.NodeName)", - "pkg/planted/planted.go: strings.EqualFold(r.NodeName, node)", + for _, f := range found { + got = append(got, strconv.Itoa(f.line)) } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("violations\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + sort.Strings(got) + sort.Strings(want) + + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("reported lines %v, want %v", got, want) } } @@ -493,7 +859,9 @@ func TestInventory(t *testing.T) { t.Skip("set NAMING_INVENTORY=1 to print every hit") } - for _, v := range violations(t, filepath.Join("..", "..")) { + root := moduleRoot(t) + + for _, v := range newGuard(t, root, "./...").violations(root) { t.Log(v) } } diff --git a/pkg/naming/testdata/planted/planted.go b/pkg/naming/testdata/planted/planted.go new file mode 100644 index 00000000..f6e57ead --- /dev/null +++ b/pkg/naming/testdata/planted/planted.go @@ -0,0 +1,139 @@ +// 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" + "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 +} + +// 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 +} From 48db68b22f6090b283d34c8f8cd6780e9aaa4c2d Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 22:54:22 +0100 Subject: [PATCH 42/51] fix(store): key in-memory replicas and snapshots by their CR names The in-memory store keyed a replica or snapshot by the keys of its two names apart, while the Kubernetes store names the CR by the key of the two joined. Outside RFC 1123 those differ: the joined key hashes the whole, so a test over the in-memory store could pass code that the Kubernetes store would send to another object. The in-memory store now uses the CR name, a conformance case pins the RFC 1123 spellings on both stores, and an in-memory test pins the hashed case the CRDs keep off a cluster. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/store/inmemory_name_key_test.go | 42 +++++++++++++++++++++++++++++ pkg/store/inmemory_resource.go | 29 ++++++++++---------- pkg/store/inmemory_snapshot.go | 21 ++++++++------- pkg/store/storetest/storetest.go | 36 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/pkg/store/inmemory_name_key_test.go b/pkg/store/inmemory_name_key_test.go index 78f9209c..80c79ea0 100644 --- a/pkg/store/inmemory_name_key_test.go +++ b/pkg/store/inmemory_name_key_test.go @@ -19,6 +19,7 @@ limitations under the License. package store_test import ( + "errors" "testing" apiv1 "github.com/cozystack/blockstor/pkg/api/v1" @@ -63,3 +64,44 @@ func TestInMemoryListingsKeepCaseVariantsOutsideRFC1123Apart(t *testing.T) { 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 59a4e6c6..461c28fe 100644 --- a/pkg/store/inmemory_resource.go +++ b/pkg/store/inmemory_resource.go @@ -30,12 +30,11 @@ import ( "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 @@ -101,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{naming.Key(rdName), naming.Key(node)}] + r, ok := s.m[resourceKey(rdName, node)] if !ok { return apiv1.Resource{}, errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -117,7 +116,7 @@ func (s *inMemoryResources) Create(_ context.Context, r *apiv1.Resource) error { s.mu.Lock() defer s.mu.Unlock() - k := rKey{naming.Key(r.Name), naming.Key(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) } @@ -135,7 +134,7 @@ func (s *inMemoryResources) Update(_ context.Context, r *apiv1.Resource) error { s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(r.Name), naming.Key(r.NodeName)} + key := resourceKey(r.Name, r.NodeName) prev, exists := s.m[key] if !exists { @@ -163,7 +162,7 @@ func (s *inMemoryResources) PatchResourceSpec(_ context.Context, rdName, node st s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(rdName), naming.Key(node)} + key := resourceKey(rdName, node) r, ok := s.m[key] if !ok { @@ -194,7 +193,7 @@ func (s *inMemoryResources) Delete(_ context.Context, rdName, node string) error s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(rdName), naming.Key(node)} + key := resourceKey(rdName, node) if _, exists := s.m[key]; !exists { return errors.Wrapf(ErrNotFound, "resource %q on node %q", rdName, node) } @@ -217,7 +216,7 @@ func (s *inMemoryResources) DeleteIfTieBreaker(_ context.Context, rdName, node s s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(rdName), naming.Key(node)} + key := resourceKey(rdName, node) r, exists := s.m[key] if !exists { @@ -247,7 +246,7 @@ func (s *inMemoryResources) SetState(_ context.Context, rdName, node string, sta s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(rdName), naming.Key(node)} + key := resourceKey(rdName, node) existing, exists := s.m[key] if !exists { @@ -285,7 +284,7 @@ func (s *inMemoryResources) ClearDRBDPort(_ context.Context, rdName, node string s.mu.Lock() defer s.mu.Unlock() - key := rKey{naming.Key(rdName), naming.Key(node)} + key := resourceKey(rdName, node) existing, ok := s.m[key] if !ok { @@ -314,7 +313,7 @@ func (s *inMemoryResources) VolumeStateForTest(rdName, node string, volumeNumber return apiv1.VolumeState{}, false } - st, ok := s.volStates[rKey{naming.Key(rdName), naming.Key(node)}][volumeNumber] + st, ok := s.volStates[resourceKey(rdName, node)][volumeNumber] return st, ok } diff --git a/pkg/store/inmemory_snapshot.go b/pkg/store/inmemory_snapshot.go index 4943c6af..0f7da052 100644 --- a/pkg/store/inmemory_snapshot.go +++ b/pkg/store/inmemory_snapshot.go @@ -30,10 +30,13 @@ import ( "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 @@ -87,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{naming.Key(rdName), naming.Key(snapName)}] + snap, ok := s.m[snapshotKey(rdName, snapName)] if !ok { return apiv1.Snapshot{}, errors.Wrapf(ErrNotFound, "snapshot %q on resource definition %q", snapName, rdName) } @@ -103,7 +106,7 @@ func (s *inMemorySnapshots) Create(_ context.Context, snap *apiv1.Snapshot) erro s.mu.Lock() defer s.mu.Unlock() - k := snapKey{naming.Key(snap.ResourceName), naming.Key(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) } @@ -121,7 +124,7 @@ func (s *inMemorySnapshots) Update(_ context.Context, snap *apiv1.Snapshot) erro s.mu.Lock() defer s.mu.Unlock() - key := snapKey{naming.Key(snap.ResourceName), naming.Key(snap.Name)} + key := snapshotKey(snap.ResourceName, snap.Name) prev, exists := s.m[key] if !exists { @@ -145,7 +148,7 @@ func (s *inMemorySnapshots) PatchSnapshotProps( s.mu.Lock() defer s.mu.Unlock() - key := snapKey{naming.Key(rdName), naming.Key(snapName)} + key := snapshotKey(rdName, snapName) snap, ok := s.m[key] if !ok { @@ -178,7 +181,7 @@ func (s *inMemorySnapshots) Delete(_ context.Context, rdName, snapName string) e s.mu.Lock() defer s.mu.Unlock() - k := snapKey{naming.Key(rdName), naming.Key(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/storetest/storetest.go b/pkg/store/storetest/storetest.go index 266ba140..9d99562f 100644 --- a/pkg/store/storetest/storetest.go +++ b/pkg/store/storetest/storetest.go @@ -510,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() @@ -2328,6 +2329,41 @@ func testAnySpellingAddressesOneReplica(t *testing.T, newStore Factory) { } } +// 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 From de3bc899e73a4712b74903cf763de36509e3ae4b Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 4 Nov 2026 23:09:23 +0100 Subject: [PATCH 43/51] test(naming): cover the shapes the name guard missed The guard saw a raw name comparison only in the shapes it knew: a key built by Sprintf or inline concatenation, a name copied through a struct field, a switch case, a closure, a call through an interface, a prefix test or a map literal went through. It now also follows names through Sprintf, Join and inline concatenation into map keys, string fields of local structs, closures bound to a variable or called in place, the function a slices.*Func call runs, methods of this module's interfaces, append, the keys of a map keyed by a name, and strings.Cut; and reports switch cases, map literal keys and prefix or substring tests between names. A parameter a helper returns stands for what each call passes, so a helper given a name taints only its own callers. Two reads of one field count as one source by where they come from, one collection or a sort's comparator, not by what the variables are called. The doc comment lists what it follows and what it cannot see. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/naming/guard_test.go | 933 ++++++++++++++++++++----- pkg/naming/testdata/planted/planted.go | 157 +++++ 2 files changed, 924 insertions(+), 166 deletions(-) diff --git a/pkg/naming/guard_test.go b/pkg/naming/guard_test.go index 6453a1f1..478cdb6f 100644 --- a/pkg/naming/guard_test.go +++ b/pkg/naming/guard_test.go @@ -19,7 +19,6 @@ limitations under the License. package naming_test import ( - "fmt" "go/ast" "go/constant" "go/token" @@ -36,11 +35,54 @@ import ( ) // The guard follows values that hold a LINSTOR object name from where they -// enter (a source) to where they are compared, folded, used as a map key, -// searched for or used to address a CR (a sink), and fails on a sink that -// naming.Key or naming.Same is not on. It works on types and on where a -// value comes from, not on what a variable is called, so a rename does not -// hide a site from it. +// 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 or +// store type (sourceFields, and Name on the wire 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; 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, or a map literal keyed, 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" @@ -66,7 +108,7 @@ func storeParams() map[string]bool { 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, "name": true, + "pool": true, "snap": true, "target": true, } } @@ -98,13 +140,33 @@ func namedTypes() map[string]bool { } } +// 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]bool + 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 map[*types.Func]bool - decls map[*types.Func]*ast.FuncDecl - info map[*ast.FuncDecl]*types.Info + // returns and fns are keyed by *types.Func or *ast.FuncLit. + returns map[any]labels + 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 { @@ -128,20 +190,14 @@ func newGuard(t *testing.T, dir string, patterns ...string) *guard { } g := &guard{ - pkgs: pkgs, tainted: map[types.Object]bool{}, keyed: map[types.Object]bool{}, returns: map[*types.Func]bool{}, - decls: map[*types.Func]*ast.FuncDecl{}, info: map[*ast.FuncDecl]*types.Info{}, + 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{}, 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 { - for _, d := range file.Decls { - if fn, ok := d.(*ast.FuncDecl); ok && fn.Body != nil { - if obj, ok := pkg.TypesInfo.Defs[fn.Name].(*types.Func); ok { - g.decls[obj] = fn - g.info[fn] = pkg.TypesInfo - } - } - } + g.collect(pkg.TypesInfo, file) } } @@ -150,6 +206,91 @@ func newGuard(t *testing.T, dir string, patterns ...string) *guard { 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 { @@ -171,26 +312,31 @@ func isConstant(info *types.Info, e ast.Expr) bool { return ok && tv.Value != nil } -// calleeOf is the function a call statically calls, if any. +// 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) - - return f + f, _ = info.Uses[fun].(*types.Func) case *ast.SelectorExpr: if sel, ok := info.Selections[fun]; ok { - f, _ := sel.Obj().(*types.Func) - - return f + 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) + } + } - f, _ := info.Uses[fun.Sel].(*types.Func) - - return f + if f == nil { + return nil } - return nil + return f.Origin() } func funcName(f *types.Func) string { @@ -199,9 +345,9 @@ func funcName(f *types.Func) string { } if recv := f.Type().(*types.Signature).Recv(); recv != nil { - t := recv.Type() + t := types.Unalias(recv.Type()) if p, ok := t.(*types.Pointer); ok { - t = p.Elem() + t = types.Unalias(p.Elem()) } if n, ok := t.(*types.Named); ok { @@ -212,6 +358,79 @@ func funcName(f *types.Func) string { 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) @@ -269,7 +488,7 @@ func (g *guard) isKey(info *types.Info, e ast.Expr) bool { case *ast.Ident: obj := info.Uses[x] - return obj != nil && g.keyed[obj] && !g.tainted[obj] + 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 { @@ -291,7 +510,7 @@ func (g *guard) nameLike(info *types.Info, e ast.Expr) bool { // isSourceField reports whether a selector reads a name field of a source // type. -func (g *guard) isSourceField(info *types.Info, sel *ast.SelectorExpr) bool { +func isSourceField(info *types.Info, sel *ast.SelectorExpr) bool { selection, ok := info.Selections[sel] if !ok || selection.Kind() != types.FieldVal { return false @@ -326,9 +545,66 @@ func (g *guard) isSourceField(info *types.Info, sel *ast.SelectorExpr) bool { 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] @@ -336,72 +612,243 @@ func (g *guard) isTainted(info *types.Info, e ast.Expr) bool { obj = info.Defs[x] } - return obj != nil && g.tainted[obj] + return g.tainted[obj] case *ast.SelectorExpr: - return g.isSourceField(info, x) + 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: - return isFlagSource(info, x) || g.isTainted(info, x.X) + if isFlagSource(info, x) { + return labels{srcLabel{}: true} + } + + return g.labelsOf(info, x.X) case *ast.SliceExpr: - return g.isTainted(info, x.X) + return g.labelsOf(info, x.X) case *ast.StarExpr: - return g.isTainted(info, x.X) + return g.labelsOf(info, x.X) + case *ast.TypeAssertExpr: + return g.labelsOf(info, x.X) case *ast.BinaryExpr: - return x.Op == token.ADD && (g.isTainted(info, x.X) || g.isTainted(info, x.Y)) - case *ast.CallExpr: - if isKeyCall(info, x) { - return false + 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 } - if isSourceCall(info, x) { - return true + 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)) } - if tv, ok := info.Types[x.Fun]; ok && tv.IsType() && len(x.Args) == 1 { - return g.isTainted(info, x.Args[0]) + 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 } + } - f := calleeOf(info, x) - if f != nil && g.returns[f] { - return true + 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) } - // A method of a value that holds names, such as url.Values.Get, - // returns one of them. - if sel, ok := ast.Unparen(x.Fun).(*ast.SelectorExpr); ok && f != nil && isString(info, x) { - return g.isTainted(info, sel.X) + 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 } - switch funcName(f) { - case "strings.TrimSpace", "strings.ToLower", "strings.ToUpper", "strings.Trim", "strings.TrimPrefix", - "strings.TrimSuffix", "strings.Split", "strings.Fields": - return len(x.Args) > 0 && g.isTainted(info, x.Args[0]) + 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 } -func (g *guard) mark(obj types.Object) bool { - if obj == nil || g.tainted[obj] { +// 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 } - g.tainted[obj] = true + if v, ok := obj.(*types.Var); ok && v.IsField() { + if !g.raw(set) { + return false + } - return true + 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, calls and returns -// until nothing changes. +// 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 fn, decl := range g.decls { - info := g.info[decl] + for key, f := range g.fns { + info := f.info - ast.Inspect(decl.Body, func(n ast.Node) bool { + 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: @@ -412,16 +859,24 @@ func (g *guard) propagate() { changed = g.assign(info, lhs, s.Values) || changed case *ast.RangeStmt: - // A map's keys are not its values: ranging over a - // map of names yields its values only. - if g.isTainted(info, s.X) && s.Value != nil { - changed = g.markExpr(info, s.Value) || changed + // 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 len(s.Results) > 0 && g.isTainted(info, s.Results[0]) && !g.returns[fn] { - g.returns[fn] = 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 } @@ -432,6 +887,37 @@ func (g *guard) propagate() { } } +// 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 == "_" { @@ -445,8 +931,27 @@ func identObj(info *types.Info, e ast.Expr) types.Object { return info.Uses[id] } -func (g *guard) markExpr(info *types.Info, e ast.Expr) bool { - return g.mark(identObj(info, e)) +// 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 { @@ -466,77 +971,104 @@ func (g *guard) assign(info *types.Info, lhs, rhs []ast.Expr) bool { switch { case len(lhs) == len(rhs): for i := range lhs { - if g.isTainted(info, rhs[i]) { - changed = g.markExpr(info, lhs[i]) || changed + 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: - if g.isTainted(info, rhs[0]) { - changed = g.markExpr(info, 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 } -// passArgs taints the parameters a call passes names into and, the other -// way, the arguments a call passes as names: to the store, or to a -// parameter that holds names. -func (g *guard) passArgs(info *types.Info, call *ast.CallExpr) bool { - f := calleeOf(info, call) - if f == nil { +// 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.Pkg() != nil && f.Pkg().Path() == module+"/pkg/store" { + 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) || changed + changed = g.markExpr(info, arg, labels{srcLabel{}: true}) || changed } } } - decl, ok := g.decls[f] - if !ok { - return changed - } - - calleeInfo := g.info[decl] - - var params []types.Object - - for _, field := range decl.Type.Params.List { - for _, id := range field.Names { - params = append(params, calleeInfo.Defs[id]) + // 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 i, arg := range call.Args { - if i >= len(params) { - continue - } + for _, key := range g.callees(info, call) { + params := g.params(key) - switch { - case g.isTainted(info, arg): - changed = g.mark(params[i]) || changed - case g.tainted[params[i]] && isString(info, arg) && !g.isKey(info, arg): - changed = g.markExpr(info, arg) || changed + 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 of two elements of -// one collection, or of a comparator's two arguments: one source, not two -// spellings. -func sameShape(a, b ast.Expr) bool { +// 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) @@ -544,19 +1076,32 @@ func sameShape(a, b ast.Expr) bool { return false } - if pa, ok := sa.X.(*ast.Ident); ok { - if pb, ok := sb.X.(*ast.Ident); ok { - switch pa.Name + "," + pb.Name { - case "a,b", "left,right", "x,y", "lhs,rhs": - return true - } - } + 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 } - ia, oka := sa.X.(*ast.IndexExpr) - ib, okb := sb.X.(*ast.IndexExpr) + ra, rb := root(info, ia.X), root(info, ib.X) - return oka && okb && fmt.Sprint(ia.X) == fmt.Sprint(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 { @@ -629,7 +1174,7 @@ func (g *guard) findings(root string) []finding { } ast.Inspect(file, func(n ast.Node) bool { - if hit := g.sink(info, n); hit != nil { + for _, hit := range g.sinks(info, n) { out = append(out, finding{file: rel, line: pkg.Fset.Position(hit.Pos()).Line, code: code(hit)}) } @@ -644,72 +1189,111 @@ func (g *guard) findings(root string) []finding { // 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) } -// sink returns the node to report when n is a sink a source reaches. -func (g *guard) sink(info *types.Info, n ast.Node) ast.Node { +// 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) { - return nil + 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} } - - if isConstant(info, e.X) || isConstant(info, e.Y) || sameShape(e.X, e.Y) { + case *ast.SwitchStmt: + if e.Tag == nil || !isString(info, e.Tag) { return nil } - if g.rawPair(info, e.X, e.Y) { - return e + 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: - m, ok := info.TypeOf(e.X).Underlying().(*types.Map) - if !ok { - return nil + 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) + } - if k, ok := m.Key().Underlying().(*types.Basic); !ok || k.Info()&types.IsString == 0 { - return nil - } + return nil +} - if bin, ok := ast.Unparen(e.Index).(*ast.BinaryExpr); ok && bin.Op == token.ADD { - return nil +func (g *guard) callSinks(info *types.Info, call *ast.CallExpr) []ast.Node { + 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} } - - if !isKeyCall(info, e.Index) && !isConstant(info, e.Index) && g.isTainted(info, e.Index) { - return e + case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": + if g.anyTainted(info, call.Args) { + return []ast.Node{call} } - case *ast.CallExpr: - switch funcName(calleeOf(info, e)) { - case "slices.Contains", "slices.Index": - if len(e.Args) == 2 && !isConstant(info, e.Args[1]) && g.rawPair(info, e.Args[0], e.Args[1]) { - return e - } - case "strings.EqualFold", "strings.ToLower", "strings.ToUpper": - for _, arg := range e.Args { - if g.isTainted(info, arg) { - return e - } + } + + 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 *ast.CompositeLit: - if !isCRAddress(info, e) { - return nil - } - - for _, elt := range e.Elts { + 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) { - return kv + out = append(out, kv) } } } - return nil + return out } // excludedDirs are the trees the rule does not reach, and why. @@ -732,6 +1316,10 @@ func excludedFiles() map[string]string { return map[string]string{ "pkg/satellite/reconciler.go": "works on the intent the dispatcher built and on the kernel; " + "every name in it comes from that one intent", + "pkg/store/inmemory_physicaldevice.go": "the in-memory store backend, asked in one spelling as the " + + "Kubernetes one is", + "pkg/storage/exec.go": "the scripted command runner tests drive the storage backends with, keyed by " + + "the exact command line a test expects", } } @@ -753,10 +1341,21 @@ func excluded(rel string) bool { // 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" return map[string]string{ - "cmd/satellite/coldstart_owned.go: healthy[name]": "" + - "kernel resource names on both sides, from one drbdsetup listing", + "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, @@ -774,6 +1373,8 @@ func allowed() map[string]string { "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", } diff --git a/pkg/naming/testdata/planted/planted.go b/pkg/naming/testdata/planted/planted.go index f6e57ead..67a6704e 100644 --- a/pkg/naming/testdata/planted/planted.go +++ b/pkg/naming/testdata/planted/planted.go @@ -23,6 +23,7 @@ package planted import ( "context" + "fmt" "net/http" "slices" "strings" @@ -125,6 +126,132 @@ 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 +} + // Shapes it leaves alone. func fine(req *http.Request, r apiv1.Resource, dev apiv1.PhysicalDevice, byKey map[string]int) bool { a := req.PathValue("node") @@ -137,3 +264,33 @@ func fine(req *http.Request, r apiv1.Resource, dev apiv1.PhysicalDevice, byKey m 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} + + return k > 0 && m["fixed"] && strings.HasPrefix(r.NodeName, "pvc-") && echo("lit") == r.NodeName +} From 271cec3c1f6552ac9993d7b5e0c742a650efbcf8 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 00:16:16 +0100 Subject: [PATCH 44/51] fix(controller): never prune a migration's own destination A migration marker names its source node as given. A marker spelled Node-1 on the replica on node-1 names that replica itself, and once it reached UpToDate the controller deleted it as the source: the only copy the migration made. The controller now drops such a marker and leaves the replica, pruneSrc refuses to delete the replica it was handed as the destination, and the REST migrate-disk path refuses one node in two spellings before stamping anything. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- .../controller/node_spelling_lookups_test.go | 34 ++++++++++++++- .../resource_migration_controller.go | 24 +++++++++-- .../resource_migration_controller_test.go | 42 +++++++++++++++++++ pkg/rest/node_spelling_wire_test.go | 32 ++++++++++++++ pkg/rest/resource_toggle_disk.go | 11 +++++ 5 files changed, 139 insertions(+), 4 deletions(-) diff --git a/internal/controller/node_spelling_lookups_test.go b/internal/controller/node_spelling_lookups_test.go index 3f838910..7e0a14a9 100644 --- a/internal/controller/node_spelling_lookups_test.go +++ b/internal/controller/node_spelling_lookups_test.go @@ -198,7 +198,12 @@ func TestMigrationPrunesASourceOnANodeOutsideRFC1123(t *testing.T) { res := storedReplicas(t, cli)[0] - if err := (&ResourceMigrationReconciler{Client: cli}).pruneSrc(ctx, res.Spec.ResourceDefinitionName, res.Spec.NodeName); err != nil { + 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) } @@ -209,3 +214,30 @@ func TestMigrationPrunesASourceOnANodeOutsideRFC1123(t *testing.T) { 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/resource_migration_controller.go b/internal/controller/resource_migration_controller.go index bcf6bc49..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,7 +135,7 @@ func (r *ResourceMigrationReconciler) Reconcile(ctx context.Context, req ctrl.Re return ctrl.Result{RequeueAfter: migrationRequeueInterval}, nil } - err = r.pruneSrc(ctx, dst.Spec.ResourceDefinitionName, srcNode) + err = r.pruneSrc(ctx, &dst, srcNode) if err != nil { return ctrl.Result{}, err } @@ -142,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, rdName, srcNode string) error { +func (r *ResourceMigrationReconciler) pruneSrc(ctx context.Context, dst *blockstoriov1alpha1.Resource, srcNode string) error { var src blockstoriov1alpha1.Resource - err := getReplica(ctx, r.Client, rdName, srcNode, &src) + err := getReplica(ctx, r.Client, dst.Spec.ResourceDefinitionName, srcNode, &src) if err != nil { if errors.IsNotFound(err) { return nil @@ -154,6 +167,11 @@ func (r *ResourceMigrationReconciler) pruneSrc(ctx context.Context, rdName, srcN 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/pkg/rest/node_spelling_wire_test.go b/pkg/rest/node_spelling_wire_test.go index 9e72cad4..c58fb1bc 100644 --- a/pkg/rest/node_spelling_wire_test.go +++ b/pkg/rest/node_spelling_wire_test.go @@ -426,3 +426,35 @@ func TestPeerDeleteWaitFindsTheAcknowledgmentUnderTheRemovedNodesKey(t *testing. 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/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 } From 3d5399e2fabdc1d9abb93cc2936d746e650dbac8 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 00:18:14 +0100 Subject: [PATCH 45/51] fix(satellite): fold one node's snapshot status entries into one Snapshot status can hold entries for one node under two spellings, Node-1 and node-1, written before node names were compared by key. The satellite updated the first of them while the controller, which reads one entry per node, kept the last, so a suspend acknowledgment the satellite wrote was never seen and the snapshot stalled. Every status write now folds the node's entries into one, keeping what any of them recorded. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/satellite/controllers/snapshot.go | 75 ++++++++++++------- .../snapshot_node_spelling_test.go | 58 ++++++++++++++ 2 files changed, 107 insertions(+), 26 deletions(-) diff --git a/pkg/satellite/controllers/snapshot.go b/pkg/satellite/controllers/snapshot.go index adbb39fa..190fe59b 100644 --- a/pkg/satellite/controllers/snapshot.go +++ b/pkg/satellite/controllers/snapshot.go @@ -528,12 +528,15 @@ func upsertPerNodeStatusInPlace( nodeName string, mutate func(*blockstoriov1alpha1.SnapshotPerNodeStatus) bool, ) bool { - for i := range *entries { - if !naming.Same((*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} @@ -546,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: @@ -645,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 naming.Same(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 } diff --git a/pkg/satellite/controllers/snapshot_node_spelling_test.go b/pkg/satellite/controllers/snapshot_node_spelling_test.go index c36b284c..b2c8f37c 100644 --- a/pkg/satellite/controllers/snapshot_node_spelling_test.go +++ b/pkg/satellite/controllers/snapshot_node_spelling_test.go @@ -24,6 +24,7 @@ import ( 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" @@ -83,3 +84,60 @@ func TestFetcherResolvesAPeerListedInTheRegisteredSpelling(t *testing.T) { 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) + } +} From c5ce7e1f0593f12c1ca08bd28b6d4227eb75eba0 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 00:23:14 +0100 Subject: [PATCH 46/51] fix(satellite): keep a peer whose spelling changed out of del-peer The removed-peer diff compared the peers a .res file was rendered with against the peers the new intent names, both as written. A peer spelled Node-2 in the file and node-2 in the intent read as gone, and the satellite ran del-peer against a peer that is still there. Both sides are compared by key now; the names it returns are still the ones the kernel knows. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/satellite/reconciler.go | 8 ++-- .../removed_peers_spelling_internal_test.go | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 pkg/satellite/removed_peers_spelling_internal_test.go diff --git a/pkg/satellite/reconciler.go b/pkg/satellite/reconciler.go index 8a3740b2..9dc2db49 100644 --- a/pkg/satellite/reconciler.go +++ b/pkg/satellite/reconciler.go @@ -1921,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) } } 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) + } +} From e1997746c3e5ddbad49f27a0a2c170d9db1a5251 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 00:23:14 +0100 Subject: [PATCH 47/51] fix(drbd): match a connection's paths to hosts in any spelling The renderer looked a pair's paths up by comparing the connection's node names with the host names exactly. The satellite maps the connection onto the hosts' spelling before rendering, so this held only as long as that mapping did; matching by key here keeps the paths of Node-1/beta on the host node-1 without depending on it. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/drbd/conffile.go | 9 ++++-- pkg/drbd/lookup_paths_spelling_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 pkg/drbd/lookup_paths_spelling_test.go 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) + } +} From b89eca002a89177674f99a6d56f9d51765694bb6 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 00:23:14 +0100 Subject: [PATCH 48/51] test(naming): check the satellite and DRBD code, function by function Whole files and directories were out of the guard's reach, so a raw comparison in the satellite reconciler, between connection names and host names from another source, went unseen. Exclusions now name the single functions that need one, with the reason, and the intent and DRBD types are sources. The guard also reports a map entry deleted by a raw name, and treats a helper returning naming.Key's result and a CR's GetName() as keys, so comparing either with a raw name is reported. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/naming/guard_test.go | 153 +++++++++++++++++++------ pkg/naming/testdata/planted/planted.go | 33 +++++- 2 files changed, 151 insertions(+), 35 deletions(-) diff --git a/pkg/naming/guard_test.go b/pkg/naming/guard_test.go index 478cdb6f..fd9c2e6f 100644 --- a/pkg/naming/guard_test.go +++ b/pkg/naming/guard_test.go @@ -39,8 +39,9 @@ import ( // 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 or -// store type (sourceFields, and Name on the wire types in namedTypes); a +// 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 @@ -65,11 +66,13 @@ import ( // 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; a -// switch on a name with a case of another; strings.HasPrefix, HasSuffix, +// 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, or a map literal keyed, by a raw name or a key built from one; +// 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. @@ -121,23 +124,32 @@ func flagKeys() map[string]bool { } // sourcePackages hold the types whose name fields are sources: the wire API, -// the CRDs' specs, the REST surface, the CLI's parsed arguments and the -// store's own types. +// 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 + "/internal/cli": true, module + "/pkg/store": true, module + "/pkg/satellite/intent": true, + module + "/pkg/drbd": true, } } -// namedTypes are the wire types whose own Name is an object name, in the -// spelling it was registered under. A CRD's own Name is its CR name, a key, -// and so is not a source. +// 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 { - return map[string]bool{ - "Node": true, "ResourceDefinition": true, "ResourceGroup": true, "Resource": true, - "ResourceWithVolumes": true, "Snapshot": true, "StoragePool": true, "StoragePoolDefinition": true, + 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 @@ -158,7 +170,9 @@ type guard struct { keyed map[types.Object]bool // returns and fns are keyed by *types.Func or *ast.FuncLit. returns map[any]labels - fns map[any]*fn + // 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 @@ -191,7 +205,7 @@ func newGuard(t *testing.T, dir string, patterns ...string) *guard { 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{}, fns: map[any]*fn{}, litOf: map[types.Object]*ast.FuncLit{}, + 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{}, } @@ -484,7 +498,20 @@ func isFlagSource(info *types.Info, e *ast.IndexExpr) bool { func (g *guard) isKey(info *types.Info, e ast.Expr) bool { switch x := ast.Unparen(e).(type) { case *ast.CallExpr: - return isKeyCall(info, x) + 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] @@ -539,7 +566,7 @@ func isSourceField(info *types.Info, sel *ast.SelectorExpr) bool { name := sel.Sel.Name if name == "Name" { - return named.Obj().Pkg().Path() == module+"/pkg/api/v1" && namedTypes()[named.Obj().Name()] + return namedTypes()[named.Obj().Pkg().Path()+"."+named.Obj().Name()] } return sourceFields()[name] @@ -869,6 +896,11 @@ func (g *guard) propagate() { 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]) @@ -1174,6 +1206,12 @@ func (g *guard) findings(root string) []finding { } 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)}) } @@ -1255,6 +1293,13 @@ func (g *guard) sinks(info *types.Info, n ast.Node) []ast.Node { } 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": @@ -1303,31 +1348,54 @@ func excludedDirs() map[string]string { "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", - "pkg/linstormigrate": "reads one LINSTOR database dump, whose rows name each other in the " + - "spelling LINSTOR stored, and compares them only with each other; the names it " + - "writes go through k8s.Name", - "pkg/drbd": "kernel side: compares names DRBD reports back with each other", - "third_party": "vendored code", + "third_party": "vendored code", } } -// excludedFiles are single files out of reach, and why. -func excludedFiles() map[string]string { +// 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/satellite/reconciler.go": "works on the intent the dispatcher built and on the kernel; " + - "every name in it comes from that one intent", - "pkg/store/inmemory_physicaldevice.go": "the in-memory store backend, asked in one spelling as the " + - "Kubernetes one is", - "pkg/storage/exec.go": "the scripted command runner tests drive the storage backends with, keyed by " + - "the exact command line a test expects", + "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 excluded(rel string) bool { - if _, ok := excludedFiles()[rel]; ok { - return true +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 @@ -1344,8 +1412,25 @@ func allowed() map[string]string { 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, diff --git a/pkg/naming/testdata/planted/planted.go b/pkg/naming/testdata/planted/planted.go index 67a6704e..200b4756 100644 --- a/pkg/naming/testdata/planted/planted.go +++ b/pkg/naming/testdata/planted/planted.go @@ -252,6 +252,34 @@ func viaMapKeys(req *http.Request, r apiv1.Resource) bool { 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") @@ -291,6 +319,9 @@ func keyedShapes(req *http.Request, r apiv1.Resource, byPair map[string]int) boo } 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 + return k > 0 && m["fixed"] && strings.HasPrefix(r.NodeName, "pvc-") && echo("lit") == r.NodeName && + keyOf(r.NodeName) == naming.Key(req.PathValue("node")) } From d4622b5976d34c9050c0306844a62ecbbf5367b9 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 13:12:00 +0100 Subject: [PATCH 49/51] fix(placer): place one definition at a time, counting live replicas The REST autoplace, the resource-group reconcilers and node replacement all fill a definition up to its place count: each counts the replicas there are and adds the rest. Two of them on one definition at once, or one right after another before the informer cache caught up, each counted the other's replicas as missing, and the definition got more diskful replicas than it asked for, on whatever pool the second filter allowed. Placement now holds a per-definition lock in the process and counts replicas from the API server. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- pkg/placer/concurrent_place_test.go | 149 ++++++++++++++++++++++++++++ pkg/placer/placer.go | 22 +++- 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 pkg/placer/concurrent_place_test.go 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/placer.go b/pkg/placer/placer.go index f4654b15..b77fb4c3 100644 --- a/pkg/placer/placer.go +++ b/pkg/placer/placer.go @@ -31,6 +31,7 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/cockroachdb/errors" @@ -139,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. @@ -160,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 @@ -304,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") } From a5a9b867df29bc4e195a6d37612ebd04b14176f1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 13:12:00 +0100 Subject: [PATCH 50/51] test(integration): start placing only once seeded pools have capacity A test that places right after seeding the cluster can reach the placer before the mock satellite's first capacity stamp has reached the manager's cache. Every pool then reads as full, the spawn defers its placement to the shortfall replay, and a test waiting for replicas times out. Seeding now returns once every seeded pool shows free capacity in that cache. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- tests/integration/harness/fixtures.go | 35 ++++++++++++++++++ .../integration/harness/seed_capacity_test.go | 36 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/integration/harness/seed_capacity_test.go 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) + } + } +} From 73913f5d5f016720789abae13b47ec9e7ace7e57 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Thu, 5 Nov 2026 13:12:00 +0100 Subject: [PATCH 51/51] test(integration): pin the missing-pool test's group to the pool too The resource group's rebalance pass can place a definition before the test's own autoplace does, with the group's filter, which allows any pool. The test then saw a replica on the node with the missing pool, backed by another of its pools. The definition now sits in a group with the same pool constraint, so whichever placement comes first is held to it, and the test waits for the missing pool in the manager's cache, which the placer reads. Assisted-by: LLM Signed-off-by: Andrei Kvapil --- tests/integration/group_k_test.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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)