Skip to content

Commit 8ff7043

Browse files
kvapsclaude
andcommitted
feat(layer-stack): plumb LayerStack RD→dispatcher→satellite (Phase 9)
Foundation for Phase 9 — single-replica local mode (no DRBD) and LUKS layering. This commit lands the data path: RD CRD → ResourceDefinition.Spec.LayerStack (default empty) REST → ResourceDefinition.LayerStack on the wire shape store/k8s → CRD ↔ wire converter passes LayerStack through proto → DesiredResource.layer_stack on the gRPC dispatch dispatcher → reads RD.Spec.LayerStack on every Apply satellite → applyOne skips applyDRBD when layer_stack omits "DRBD" Empty layer_stack defaults to ["DRBD","STORAGE"] so legacy clients (and the Phase-1..8 wire) keep getting full DRBD treatment unchanged. An RD with explicit `layerStack: ["STORAGE"]` provisions just the storage volume — no .res render, no drbdadm. LUKS layering (next slice) will hook in by inserting cryptsetup open/close between applyStorage and applyDRBD when "LUKS" is in the stack. Tests: - pkg/api/v1/layer_stack_test.go pins ResolveLayerStack RD→RG→default - pkg/satellite/reconciler_drbd_test.go TestApplySkipsDRBDWhenLayerStackOmits confirms a [STORAGE]-only Resource produces no .res and no drbdadm Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent a3cc4f9 commit 8ff7043

12 files changed

Lines changed: 513 additions & 222 deletions

File tree

api/v1alpha1/resourcedefinition_types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ type ResourceDefinitionSpec struct {
4545
// volumeDefinitions are the volume slots inside this RD.
4646
// +optional
4747
VolumeDefinitions []ResourceDefinitionVolume `json:"volumeDefinitions,omitempty"`
48+
49+
// layerStack is the LINSTOR layer composition for this RD's
50+
// satellite-side render — `["DRBD","STORAGE"]` (default) renders a
51+
// .res file and runs drbdadm; `["LUKS","STORAGE"]` layers
52+
// cryptsetup over the storage device with no DRBD; `["STORAGE"]`
53+
// is single-replica local mode (no replication, no encryption).
54+
// Order is top-down: the first layer's device is what the
55+
// consumer Pod mounts, the last is the raw block device the
56+
// storage provider creates.
57+
// Empty = inherits from the parent ResourceGroup; both empty =
58+
// `["DRBD","STORAGE"]`.
59+
// +optional
60+
LayerStack []string `json:"layerStack,omitempty"`
4861
}
4962

5063
// ResourceDefinitionVolume is one volume slot inside an RD.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ spec:
5151
items:
5252
type: string
5353
type: array
54+
layerStack:
55+
description: |-
56+
layerStack is the LINSTOR layer composition for this RD's
57+
satellite-side render — `["DRBD","STORAGE"]` (default) renders a
58+
.res file and runs drbdadm; `["LUKS","STORAGE"]` layers
59+
cryptsetup over the storage device with no DRBD; `["STORAGE"]`
60+
is single-replica local mode (no replication, no encryption).
61+
Order is top-down: the first layer's device is what the
62+
consumer Pod mounts, the last is the raw block device the
63+
storage provider creates.
64+
Empty = inherits from the parent ResourceGroup; both empty =
65+
`["DRBD","STORAGE"]`.
66+
items:
67+
type: string
68+
type: array
5469
props:
5570
additionalProperties:
5671
type: string

pkg/api/v1/layer_stack.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1
18+
19+
import "strings"
20+
21+
// ResolveLayerStack picks the layer composition for an RD by walking
22+
// the upstream LINSTOR scope hierarchy: RD → RG → default. The first
23+
// non-empty entry wins. Same precedence rule as
24+
// ResourceGroup.SelectFilter inheritance.
25+
//
26+
// Empty arrays at every level fall through to DefaultLayerStack
27+
// (`["DRBD","STORAGE"]`).
28+
func ResolveLayerStack(rdLayers, rgLayers []string) []string {
29+
if len(rdLayers) > 0 {
30+
return rdLayers
31+
}
32+
33+
if len(rgLayers) > 0 {
34+
return rgLayers
35+
}
36+
37+
return DefaultLayerStack()
38+
}
39+
40+
// LayerInStack reports whether kind is present in stack
41+
// (case-insensitive). Used by the dispatcher / satellite to decide
42+
// whether to render a `.res` or just provision the storage layer.
43+
func LayerInStack(stack []string, kind string) bool {
44+
for _, s := range stack {
45+
if strings.EqualFold(s, kind) {
46+
return true
47+
}
48+
}
49+
50+
return false
51+
}

pkg/api/v1/layer_stack_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1_test
18+
19+
import (
20+
"slices"
21+
"testing"
22+
23+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
24+
)
25+
26+
// TestResolveLayerStack: RD wins over RG; both empty → default.
27+
func TestResolveLayerStack(t *testing.T) {
28+
t.Parallel()
29+
30+
cases := []struct {
31+
name string
32+
rd []string
33+
rg []string
34+
want []string
35+
}{
36+
{
37+
name: "RD set, RG ignored",
38+
rd: []string{"LUKS", "STORAGE"},
39+
rg: []string{"DRBD", "STORAGE"},
40+
want: []string{"LUKS", "STORAGE"},
41+
},
42+
{
43+
name: "RD empty, RG wins",
44+
rg: []string{"DRBD", "LUKS", "STORAGE"},
45+
want: []string{"DRBD", "LUKS", "STORAGE"},
46+
},
47+
{
48+
name: "both empty → default",
49+
want: []string{"DRBD", "STORAGE"},
50+
},
51+
{
52+
name: "RD empty slice → default",
53+
rd: []string{},
54+
rg: []string{},
55+
want: []string{"DRBD", "STORAGE"},
56+
},
57+
}
58+
59+
for _, c := range cases {
60+
t.Run(c.name, func(t *testing.T) {
61+
t.Parallel()
62+
63+
got := apiv1.ResolveLayerStack(c.rd, c.rg)
64+
if !slices.Equal(got, c.want) {
65+
t.Errorf("got %v, want %v", got, c.want)
66+
}
67+
})
68+
}
69+
}
70+
71+
// TestLayerInStack: case-insensitive presence check.
72+
func TestLayerInStack(t *testing.T) {
73+
t.Parallel()
74+
75+
stack := []string{"DRBD", "STORAGE"}
76+
77+
if !apiv1.LayerInStack(stack, "DRBD") {
78+
t.Errorf("DRBD should be in %v", stack)
79+
}
80+
81+
if !apiv1.LayerInStack(stack, "drbd") {
82+
t.Errorf("case-insensitive match for drbd should hit DRBD")
83+
}
84+
85+
if apiv1.LayerInStack(stack, "LUKS") {
86+
t.Errorf("LUKS not in %v", stack)
87+
}
88+
89+
if apiv1.LayerInStack(nil, "DRBD") {
90+
t.Errorf("empty stack should not match anything")
91+
}
92+
}

pkg/api/v1/resource_definition.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,24 @@ type ResourceDefinition struct {
2525
Props map[string]string `json:"props,omitempty"`
2626
Flags []string `json:"flags,omitempty"`
2727
LayerData []ResourceLayer `json:"layer_data,omitempty"`
28-
UUID string `json:"uuid,omitempty"`
28+
// LayerStack is the layer composition (e.g. ["DRBD","STORAGE"]).
29+
// Wire field name matches upstream LINSTOR (`resource_definition.layer_stack`).
30+
LayerStack []string `json:"layer_stack,omitempty"`
31+
UUID string `json:"uuid,omitempty"`
32+
}
33+
34+
// Layer kind constants — the strings LINSTOR uses on the wire.
35+
const (
36+
LayerKindDRBD = "DRBD"
37+
LayerKindLUKS = "LUKS"
38+
LayerKindStorage = "STORAGE"
39+
)
40+
41+
// DefaultLayerStack returns the layer stack used when the RD spec
42+
// (and its parent RG) leave it empty. Matches upstream LINSTOR's
43+
// default — full DRBD-over-STORAGE replication.
44+
func DefaultLayerStack() []string {
45+
return []string{LayerKindDRBD, LayerKindStorage}
2946
}
3047

3148
// ResourceLayer is the per-layer descriptor on a ResourceDefinition. We

pkg/dispatcher/dispatcher.go

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
"crypto/sha256"
2626
"encoding/binary"
2727
"slices"
28-
"sort"
2928
"strconv"
3029
"strings"
3130

@@ -157,8 +156,6 @@ func lookupEndpoint(nodeName string, nodes []blockstoriov1alpha1.Node) string {
157156
// `peer.<name>.address` carries a real (pod) IP rather than a 0.0.0.0
158157
// placeholder; drbd-9 won't replicate to 0.0.0.0.
159158
func buildDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alpha1.Resource, nodes []blockstoriov1alpha1.Node, rd *blockstoriov1alpha1.ResourceDefinition, effectiveProps map[string]string) *satellitepb.DesiredResource {
160-
rdName := target.Spec.ResourceDefinitionName
161-
162159
// node-id and port/minor are persisted on Status by the controller
163160
// before Apply runs. Falling back to derive*() is a transitional
164161
// safety net for the case where the allocator hasn't run yet — it
@@ -188,20 +185,15 @@ func buildDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alp
188185
}
189186
}
190187

191-
// Stable iteration: sort peer names so the satellite-side .res
192-
// renderer sees a deterministic order. node-id itself is stable
193-
// regardless of iteration order because it's persisted.
194188
dropped := make([]string, 0, len(idOf))
195189

196190
for name := range idOf {
197-
if name == target.Spec.NodeName {
198-
continue
191+
if name != target.Spec.NodeName {
192+
dropped = append(dropped, name)
199193
}
200-
201-
dropped = append(dropped, name)
202194
}
203195

204-
sort.Strings(dropped)
196+
slices.Sort(dropped)
205197

206198
drbdOpts := map[string]string{
207199
"port": strconv.Itoa(port),
@@ -224,16 +216,30 @@ func buildDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alp
224216

225217
addPeerEntries(drbdOpts, dropped, peers, nodes, port, idOf)
226218

219+
return assembleDesired(target, peers, rd, dropped, drbdOpts, effectiveProps)
220+
}
221+
222+
// assembleDesired packages the per-replica wire payload. Pulled out
223+
// of buildDesired so the caller stays under the funlen budget.
224+
func assembleDesired(target *blockstoriov1alpha1.Resource, peers []blockstoriov1alpha1.Resource, rd *blockstoriov1alpha1.ResourceDefinition, dropped []string, drbdOpts, effectiveProps map[string]string) *satellitepb.DesiredResource {
225+
_ = peers // peers info already folded into drbdOpts via addPeerEntries
226+
227227
wireProps := mergeEffectiveProps(target.Spec.Props, effectiveProps, drbdOpts)
228228

229+
var layerStack []string
230+
if rd != nil {
231+
layerStack = rd.Spec.LayerStack
232+
}
233+
229234
return &satellitepb.DesiredResource{
230-
Name: rdName,
235+
Name: target.Spec.ResourceDefinitionName,
231236
NodeName: target.Spec.NodeName,
232237
Flags: target.Spec.Flags,
233238
Props: wireProps,
234239
Peers: dropped,
235240
Volumes: buildVolumes(rd, target),
236241
DrbdOptions: drbdOpts,
242+
LayerStack: layerStack,
237243
}
238244
}
239245

0 commit comments

Comments
 (0)