Skip to content

Commit 9d285ae

Browse files
kvapsclaude
andcommitted
feat(storage): shared-LUN architectural hooks (Phase 8.5)
Add SharedSpaceID to StoragePool CRD spec and REST shape. Pools sharing a backing LUN (e.g. EXOS / NetApp / Ceph-RBD-as-shared-disk slices attached to multiple satellites) now contribute their free capacity once to cluster totals instead of summing, and the autoplacer treats them as anti-affine so a 2-replica RD never lands on pools that physically share the same disk. The SAN-shared-LUN model isn't needed today, but landing the hooks now keeps the door open without retrofitting the placer/query-size-info paths later. Empty SharedSpaceID = local pool (default), so existing behaviour is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent f761254 commit 9d285ae

10 files changed

Lines changed: 328 additions & 26 deletions

api/v1alpha1/storagepool_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ type StoragePoolSpec struct {
4040
// +kubebuilder:validation:Enum=LVM;LVM_THIN;ZFS;ZFS_THIN;FILE;FILE_THIN;DISKLESS
4141
ProviderKind string `json:"providerKind"`
4242

43+
// sharedSpaceId groups pools that physically share the same backing
44+
// LUN (e.g. an EXOS / NetApp / Ceph-RBD-as-shared-disk slice attached
45+
// to multiple satellites). Pools in the same group contribute their
46+
// free capacity once to cluster totals instead of summing, and the
47+
// autoplacer treats them as anti-affine for replica placement so a
48+
// 2-replica RD never lands twice on the same physical LUN.
49+
// Empty string = local pool (default).
50+
// +optional
51+
SharedSpaceID string `json:"sharedSpaceId,omitempty"`
52+
4353
// props is the LINSTOR property map for this pool.
4454
// +optional
4555
Props map[string]string `json:"props,omitempty"`

config/crd/bases/blockstor.io.blockstor.io_resources.yaml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ spec:
124124
x-kubernetes-list-type: map
125125
drbdMinor:
126126
description: |-
127-
drbdMinor is the local /dev/drbd<N> minor number. Same scoping
128-
rule as drbdPort.
127+
drbdMinor is the local /dev/drbd<N> minor number on the
128+
hosting node. Like drbdPort, allocated per-replica from the
129+
node's minor-range — minors are local device numbers, so two
130+
replicas on different nodes can have unrelated minors.
129131
format: int32
130132
type: integer
131133
drbdNodeId:
@@ -140,9 +142,11 @@ spec:
140142
type: integer
141143
drbdPort:
142144
description: |-
143-
drbdPort is the TCP port the controller allocated for this RD's
144-
replication mesh. All replicas of the same RD share the port;
145-
the controller stamps the same value on every sibling Resource.
145+
drbdPort is the TCP port this replica listens on. Allocated
146+
from the hosting node's TCP-port range — different replicas
147+
of the same RD can use different ports because each lives on
148+
a different node and the port is local to that node. Matches
149+
upstream LINSTOR's per-resource (not per-RD) port model.
146150
nil means not yet allocated.
147151
format: int32
148152
type: integer

config/crd/bases/blockstor.io.blockstor.io_storagepools.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ spec:
6565
- FILE_THIN
6666
- DISKLESS
6767
type: string
68+
sharedSpaceId:
69+
description: |-
70+
sharedSpaceId groups pools that physically share the same backing
71+
LUN (e.g. an EXOS / NetApp / Ceph-RBD-as-shared-disk slice attached
72+
to multiple satellites). Pools in the same group contribute their
73+
free capacity once to cluster totals instead of summing, and the
74+
autoplacer treats them as anti-affine for replica placement so a
75+
2-replica RD never lands twice on the same physical LUN.
76+
Empty string = local pool (default).
77+
type: string
6878
required:
6979
- nodeName
7080
- providerKind

pkg/api/v1/storage_pool.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@ type StoragePool struct {
2727
FreeCapacity int64 `json:"free_capacity,omitempty"`
2828
TotalCapacity int64 `json:"total_capacity,omitempty"`
2929
FreeSpaceMgrName string `json:"free_space_mgr_name,omitempty"`
30-
Reports []APICallRc `json:"reports,omitempty"`
31-
SupportsSnapshot bool `json:"supports_snapshots,omitempty"`
32-
ExternalLocking bool `json:"external_locking,omitempty"`
33-
UUID string `json:"uuid,omitempty"`
30+
// SharedSpaceID groups pools that physically share a backing LUN.
31+
// Empty = local pool (default). Pools sharing the same value
32+
// contribute one free-capacity figure to cluster totals (instead
33+
// of summing) and are treated as anti-affine by the autoplacer.
34+
// Wire field name matches upstream LINSTOR (`shared_space`).
35+
SharedSpaceID string `json:"shared_space,omitempty"`
36+
Reports []APICallRc `json:"reports,omitempty"`
37+
SupportsSnapshot bool `json:"supports_snapshots,omitempty"`
38+
ExternalLocking bool `json:"external_locking,omitempty"`
39+
UUID string `json:"uuid,omitempty"`
3440
}
3541

3642
// APICallRc is the upstream `ApiCallRc` envelope. We define a minimal subset

pkg/placer/placer.go

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ func (p *Placer) Place(ctx context.Context, rdName string, filter *apiv1.AutoSel
8080
return 0, 0, err
8181
}
8282

83-
state := newState(filter, existing, nodes)
83+
allPools, err := p.store.StoragePools().List(ctx)
84+
if err != nil {
85+
return 0, 0, errors.Wrap(err, "list storage pools")
86+
}
87+
88+
state := newState(filter, existing, nodes, allPools)
8489

8590
if state.sameTuple == nil && len(filter.ReplicasOnSame) > 0 {
8691
candidates, state.sameTuple = pickSameGroup(candidates, nodes,
@@ -170,24 +175,45 @@ func (p *Placer) placeDisklessOnRemaining(ctx context.Context, rdName string, st
170175
// state holds the per-call lookup tables. Pulled out of Placer so a
171176
// concurrent Placer call doesn't share the mutable bookkeeping.
172177
type state struct {
173-
nodes map[string]map[string]string
174-
taken map[string]struct{}
175-
sameTuple map[string]string
176-
diffSeen map[string]struct{}
177-
filter *apiv1.AutoSelectFilter
178+
nodes map[string]map[string]string
179+
taken map[string]struct{}
180+
sameTuple map[string]string
181+
diffSeen map[string]struct{}
182+
sharedSeen map[string]struct{} // sharedSpaceIDs already used by an existing replica
183+
pools map[string]apiv1.StoragePool
184+
filter *apiv1.AutoSelectFilter
178185
}
179186

180-
func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes map[string]map[string]string) *state {
187+
func newState(filter *apiv1.AutoSelectFilter, existing []apiv1.Resource, nodes map[string]map[string]string, pools []apiv1.StoragePool) *state {
181188
s := &state{
182-
nodes: nodes,
183-
taken: make(map[string]struct{}, len(existing)),
184-
sameTuple: topologyTuple(existing, nodes, filter.ReplicasOnSame),
185-
diffSeen: topologySeen(existing, nodes, filter.ReplicasOnDifferent),
186-
filter: filter,
189+
nodes: nodes,
190+
taken: make(map[string]struct{}, len(existing)),
191+
sameTuple: topologyTuple(existing, nodes, filter.ReplicasOnSame),
192+
diffSeen: topologySeen(existing, nodes, filter.ReplicasOnDifferent),
193+
sharedSeen: make(map[string]struct{}),
194+
pools: poolsByKey(pools),
195+
filter: filter,
187196
}
188197

189198
for i := range existing {
190199
s.taken[existing[i].NodeName] = struct{}{}
200+
201+
// Pre-seed shared-space anti-affinity: if an existing replica
202+
// already lives on a shared-LUN pool, no new replica may land
203+
// on a pool sharing that LUN identifier.
204+
stor := existing[i].Props["StorPoolName"]
205+
if stor == "" {
206+
continue
207+
}
208+
209+
pool, ok := s.pools[poolKey(existing[i].NodeName, stor)]
210+
if !ok {
211+
continue
212+
}
213+
214+
if pool.SharedSpaceID != "" {
215+
s.sharedSeen[pool.SharedSpaceID] = struct{}{}
216+
}
191217
}
192218

193219
return s
@@ -208,6 +234,16 @@ func (s *state) tryPlace(ctx context.Context, st store.Store, rdName string, poo
208234
return false, nil
209235
}
210236

237+
// Shared-LUN anti-affinity: pools sharing a backing LUN identifier
238+
// cannot host two replicas of the same RD — at the physical layer
239+
// they are the same disk, so a 2-replica placement onto the same
240+
// SharedSpaceID would offer zero redundancy.
241+
if pool.SharedSpaceID != "" {
242+
if _, dup := s.sharedSeen[pool.SharedSpaceID]; dup {
243+
return false, nil
244+
}
245+
}
246+
211247
res := apiv1.Resource{
212248
Name: rdName,
213249
NodeName: pool.NodeName,
@@ -229,9 +265,28 @@ func (s *state) tryPlace(ctx context.Context, st store.Store, rdName string, poo
229265
s.diffSeen[k+"="+nodeProps[auxKey(k)]] = struct{}{}
230266
}
231267

268+
if pool.SharedSpaceID != "" {
269+
s.sharedSeen[pool.SharedSpaceID] = struct{}{}
270+
}
271+
232272
return true, nil
233273
}
234274

275+
// poolKey is the composite (node, pool) lookup key used to find a
276+
// StoragePool from a Resource's StorPoolName + NodeName pair.
277+
func poolKey(node, pool string) string {
278+
return node + "\x1f" + pool
279+
}
280+
281+
func poolsByKey(pools []apiv1.StoragePool) map[string]apiv1.StoragePool {
282+
out := make(map[string]apiv1.StoragePool, len(pools))
283+
for i := range pools {
284+
out[poolKey(pools[i].NodeName, pools[i].StoragePoolName)] = pools[i]
285+
}
286+
287+
return out
288+
}
289+
235290
func (p *Placer) candidatePools(ctx context.Context, filter *apiv1.AutoSelectFilter) ([]apiv1.StoragePool, error) {
236291
all, err := p.store.StoragePools().List(ctx)
237292
if err != nil {

pkg/rest/advise.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ func pickAdvicePools(pools []apiv1.StoragePool, filter *apiv1.AutoSelectFilter,
161161
}
162162
}
163163

164+
// Shared-LUN anti-affinity: the placer would refuse to put two
165+
// replicas on pools sharing a backing LUN, so the advice surface
166+
// must mirror that — otherwise we'd recommend a layout the
167+
// resource-create would then reject.
168+
candidates = dedupShared(candidates)
169+
164170
if want > len(candidates) {
165171
want = len(candidates)
166172
}

pkg/rest/autoplace_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,120 @@ func TestAutoplaceSkipsEvictedNodes(t *testing.T) {
608608
}
609609
}
610610

611+
// TestAutoplaceSharedLUNAntiAffinity: two pools share the same backing
612+
// LUN (SharedSpaceID="exos-lun-42"); a 2-replica autoplace must never
613+
// land both replicas on those pools, even though they live on
614+
// distinct nodes. Real-world rationale: at the physical layer the
615+
// LUN is the same disk, so both replicas would sit on the same
616+
// failure domain — defeating the redundancy a 2-replica RD promises.
617+
func TestAutoplaceSharedLUNAntiAffinity(t *testing.T) {
618+
st := store.NewInMemory()
619+
ctx := t.Context()
620+
621+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-shared"}); err != nil {
622+
t.Fatalf("seed RD: %v", err)
623+
}
624+
625+
for _, n := range []string{"n1", "n2", "n3"} {
626+
if err := st.Nodes().Create(ctx, &apiv1.Node{Name: n, Type: apiv1.NodeTypeSatellite}); err != nil {
627+
t.Fatalf("seed node %s: %v", n, err)
628+
}
629+
}
630+
631+
// n1 + n2 each see the same EXOS LUN; n3 has its own local pool.
632+
pools := []apiv1.StoragePool{
633+
{StoragePoolName: "pool", NodeName: "n1", ProviderKind: apiv1.StoragePoolKindLVMThin, SharedSpaceID: "exos-lun-42", FreeCapacity: 9000},
634+
{StoragePoolName: "pool", NodeName: "n2", ProviderKind: apiv1.StoragePoolKindLVMThin, SharedSpaceID: "exos-lun-42", FreeCapacity: 9000},
635+
{StoragePoolName: "pool", NodeName: "n3", ProviderKind: apiv1.StoragePoolKindLVMThin, FreeCapacity: 5000},
636+
}
637+
for i := range pools {
638+
if err := st.StoragePools().Create(ctx, &pools[i]); err != nil {
639+
t.Fatalf("seed pool: %v", err)
640+
}
641+
}
642+
643+
base, stop := startServerWithStore(t, st)
644+
defer stop()
645+
646+
body, _ := json.Marshal(apiv1.AutoPlaceRequest{
647+
SelectFilter: apiv1.AutoSelectFilter{PlaceCount: 2, StoragePool: "pool"},
648+
})
649+
650+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-shared/autoplace", body)
651+
_ = resp.Body.Close()
652+
653+
if resp.StatusCode != http.StatusOK {
654+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
655+
}
656+
657+
got, err := st.Resources().ListByDefinition(ctx, "pvc-shared")
658+
if err != nil {
659+
t.Fatalf("list: %v", err)
660+
}
661+
662+
if len(got) != 2 {
663+
t.Fatalf("placed: got %d, want 2", len(got))
664+
}
665+
666+
// Exactly one of {n1, n2} can be picked; n3 (local) MUST be the other.
667+
nodes := []string{got[0].NodeName, got[1].NodeName}
668+
if !slices.Contains(nodes, "n3") {
669+
t.Errorf("expected one replica on n3 (the non-shared node); got %v", nodes)
670+
}
671+
672+
sharedHit := 0
673+
for _, n := range nodes {
674+
if n == "n1" || n == "n2" {
675+
sharedHit++
676+
}
677+
}
678+
679+
if sharedHit > 1 {
680+
t.Errorf("both replicas on the same shared-LUN pool group: %v", nodes)
681+
}
682+
}
683+
684+
// TestAutoplaceSharedLUNExhausted: 2-replica RD against two pools
685+
// sharing one LUN — only one replica fits, the other has no
686+
// candidate and the request must 409. Pins the conflict path.
687+
func TestAutoplaceSharedLUNExhausted(t *testing.T) {
688+
st := store.NewInMemory()
689+
ctx := t.Context()
690+
691+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-shared-2"}); err != nil {
692+
t.Fatalf("seed RD: %v", err)
693+
}
694+
695+
for _, n := range []string{"n1", "n2"} {
696+
if err := st.Nodes().Create(ctx, &apiv1.Node{Name: n, Type: apiv1.NodeTypeSatellite}); err != nil {
697+
t.Fatalf("seed node %s: %v", n, err)
698+
}
699+
700+
if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{
701+
StoragePoolName: "pool",
702+
NodeName: n,
703+
ProviderKind: apiv1.StoragePoolKindLVMThin,
704+
SharedSpaceID: "exos-lun-42",
705+
}); err != nil {
706+
t.Fatalf("seed pool %s: %v", n, err)
707+
}
708+
}
709+
710+
base, stop := startServerWithStore(t, st)
711+
defer stop()
712+
713+
body, _ := json.Marshal(apiv1.AutoPlaceRequest{
714+
SelectFilter: apiv1.AutoSelectFilter{PlaceCount: 2, StoragePool: "pool"},
715+
})
716+
717+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-shared-2/autoplace", body)
718+
_ = resp.Body.Close()
719+
720+
if resp.StatusCode != http.StatusConflict {
721+
t.Errorf("status: got %d, want 409 (only one shared-LUN slot fits)", resp.StatusCode)
722+
}
723+
}
724+
611725
// TestResourceListAndGet: GET /v1/resource-definitions/{rd}/resources
612726
// returns all replicas wrapped as ResourceWithVolumes; the per-node
613727
// GET returns one entry or 404 when missing. linstor-csi's reconciler

pkg/rest/query_size_info.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ func (s *Server) computeSizeInfo(ctx context.Context, filter *apiv1.AutoSelectFi
106106
)
107107

108108
candidates := make([]apiv1.StoragePool, 0, len(pools))
109+
// sharedSeen collapses pools that share a backing LUN: a SAN /
110+
// EXOS / Ceph-RBD-as-shared-disk slice attached to multiple
111+
// satellites must only contribute its capacity once to cluster
112+
// totals. Without this, two pools each "seeing" 100 GiB of the
113+
// same LUN would report 200 GiB free.
114+
sharedSeen := map[string]struct{}{}
109115

110116
for i := range pools {
111117
pool := pools[i]
@@ -122,11 +128,20 @@ func (s *Server) computeSizeInfo(ctx context.Context, filter *apiv1.AutoSelectFi
122128
}
123129

124130
candidates = append(candidates, pool)
131+
132+
if pool.SharedSpaceID != "" {
133+
if _, dup := sharedSeen[pool.SharedSpaceID]; dup {
134+
continue
135+
}
136+
137+
sharedSeen[pool.SharedSpaceID] = struct{}{}
138+
}
139+
125140
totalCapacity += pool.TotalCapacity
126141
availableSum += pool.FreeCapacity
127142
}
128143

129-
maxVolKib := worstFreeOfTopN(candidates, replicaCount(filter))
144+
maxVolKib := worstFreeOfTopN(dedupShared(candidates), replicaCount(filter))
130145

131146
return querySizeInfoResponse{
132147
SpaceInfo: querySizeInfoSpaceInfo{
@@ -137,6 +152,30 @@ func (s *Server) computeSizeInfo(ctx context.Context, filter *apiv1.AutoSelectFi
137152
}, nil
138153
}
139154

155+
// dedupShared collapses pools that share a backing LUN to a single
156+
// representative. The placer's anti-affinity already prevents 2 of N
157+
// replicas from landing on the same SharedSpaceID; the n-th-largest
158+
// computation must reflect that, otherwise it would overcount the
159+
// shared LUN as N independent pools.
160+
func dedupShared(pools []apiv1.StoragePool) []apiv1.StoragePool {
161+
out := make([]apiv1.StoragePool, 0, len(pools))
162+
seen := map[string]struct{}{}
163+
164+
for i := range pools {
165+
if pools[i].SharedSpaceID != "" {
166+
if _, dup := seen[pools[i].SharedSpaceID]; dup {
167+
continue
168+
}
169+
170+
seen[pools[i].SharedSpaceID] = struct{}{}
171+
}
172+
173+
out = append(out, pools[i])
174+
}
175+
176+
return out
177+
}
178+
140179
// worstFreeOfTopN sorts pools by FreeCapacity desc and returns the
141180
// FreeCapacity of the n-th — i.e. the cap that all `n` replicas can
142181
// actually fit. Fewer than n candidates → 0 (the request can't be

0 commit comments

Comments
 (0)