Skip to content

Commit 3eba521

Browse files
kvapsclaude
andcommitted
refactor: lift effective-props resolver into pkg/effectiveprops (Phase 10.1)
Moves the Controller → RG → RD → Resource hierarchy resolver out of `internal/controller.ResourceReconciler` into a shared `pkg/effectiveprops` package so the upcoming satellite-side controller-runtime reconcilers can use the same code path without duplicating 80 lines of merge logic. `pkg/effectiveprops.Resolve(ctx, client.Reader, target, rd)` is the single entry point — takes any controller-runtime reader (both controller's mgr.GetClient() and satellite's). Internals unchanged from the previous private implementation: - typed `Spec.DRBDOptions` via drbd.ResolveDRBDOptions across ControllerConfig → RG → RD → Resource scopes; - legacy Spec.Props via drbd.ResolveOptions for forward-compat; - ExtraProps + ControllerConfig.Spec.ExtraProps overlay. `internal/controller.resolveEffectiveProps` becomes a one-line wrapper. The KVEntry-shaped legacy `controllerProps` reader is now exposed as `effectiveprops.LegacyControllerProps` so the csi-volumes filter test can pin its instance-filter invariant directly against the package. Old test file moves alongside. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent dc1f5c1 commit 3eba521

4 files changed

Lines changed: 231 additions & 172 deletions

File tree

internal/controller/export_test.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,6 @@ func (r *ResourceDefinitionReconciler) SetQuorum(ctx context.Context, rd *blocks
8484
return r.setQuorum(ctx, rd, value)
8585
}
8686

87-
// ControllerProps exposes the cluster-KV ControllerProps reader.
88-
// Tests pin the instance-filter so KVEntry rows belonging to other
89-
// instances (e.g. csi-volumes) don't leak into the prop bag the
90-
// dispatcher hands the satellite.
91-
func (r *ResourceReconciler) ControllerProps(ctx context.Context) (map[string]string, error) {
92-
return r.controllerProps(ctx)
93-
}
94-
9587
// LookupRD exposes the soft-fail RD fetcher. Tests pin the
9688
// (nil, nil) return on NotFound so the dispatcher can still set up
9789
// connection state when the RD is being deleted concurrently.

internal/controller/resource_controller.go

Lines changed: 8 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ package controller
1818

1919
import (
2020
"context"
21-
"maps"
2221
"slices"
2322
"time"
2423

@@ -35,6 +34,7 @@ import (
3534
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
3635
"github.com/cozystack/blockstor/pkg/dispatcher"
3736
"github.com/cozystack/blockstor/pkg/drbd"
37+
"github.com/cozystack/blockstor/pkg/effectiveprops"
3838
"github.com/cozystack/blockstor/pkg/store"
3939
)
4040

@@ -795,116 +795,14 @@ func (r *ResourceReconciler) takenOnNode(ctx context.Context, nodeName string, p
795795
return out, nil
796796
}
797797

798-
// resolveEffectiveProps walks the controller → RG → RD → Resource
799-
// scopes and returns the merged DRBD-options bag the dispatcher
800-
// hands to the satellite. Lower scopes override upper. Each scope is
801-
// best-effort: a missing controller-props KV instance, missing RG
802-
// reference, or missing RD all silently degrade to "empty" rather
803-
// than block the dispatch — the resource can still come up with
804-
// satellite-only defaults.
805-
//
806-
// As of Phase 10.3 step 5 the typed `Spec.DRBDOptions` is the source
807-
// of truth — we resolve via `drbd.ResolveDRBDOptions`, then flatten
808-
// to a string Props map for the satellite proto (typed proto comes
809-
// in a later step). Legacy `Spec.Props` keys + `ExtraProps` are
810-
// merged in too so a partially-migrated cluster keeps rendering
811-
// the same .res file.
798+
// resolveEffectiveProps delegates to the shared `pkg/effectiveprops`
799+
// package (Phase 10.1 lift-out) so the satellite-side reconciler
800+
// can use the same hierarchy resolution without duplicating
801+
// 80 lines of merge logic. The wrapper survives because the
802+
// existing call sites are `r.resolveEffectiveProps(ctx, target, rd)`
803+
// and we don't want to churn them.
812804
func (r *ResourceReconciler) resolveEffectiveProps(ctx context.Context, target *blockstoriov1alpha1.Resource, rdPtr *blockstoriov1alpha1.ResourceDefinition) (map[string]string, error) {
813-
controllerProps, err := r.controllerProps(ctx)
814-
if err != nil {
815-
return nil, err
816-
}
817-
818-
ctrlCfg, err := r.controllerConfig(ctx)
819-
if err != nil {
820-
return nil, err
821-
}
822-
823-
var (
824-
ctrlTyped *blockstoriov1alpha1.DRBDOptions
825-
ctrlExtras map[string]string
826-
rgProps map[string]string
827-
rdProps map[string]string
828-
rgTyped *blockstoriov1alpha1.DRBDOptions
829-
rdTyped *blockstoriov1alpha1.DRBDOptions
830-
rgExtras map[string]string
831-
rdExtras map[string]string
832-
)
833-
834-
if ctrlCfg != nil {
835-
ctrlTyped = ctrlCfg.Spec.DRBDOptions
836-
ctrlExtras = ctrlCfg.Spec.ExtraProps
837-
}
838-
839-
if rdPtr != nil {
840-
rdProps = rdPtr.Spec.Props
841-
rdTyped = rdPtr.Spec.DRBDOptions
842-
rdExtras = rdPtr.Spec.ExtraProps
843-
844-
if rdPtr.Spec.ResourceGroupName != "" {
845-
var rg blockstoriov1alpha1.ResourceGroup
846-
847-
err := r.Get(ctx, client.ObjectKey{Name: rdPtr.Spec.ResourceGroupName}, &rg)
848-
switch {
849-
case err == nil:
850-
rgProps = rg.Spec.Props
851-
rgTyped = rg.Spec.DRBDOptions
852-
rgExtras = rg.Spec.ExtraProps
853-
case errors.IsNotFound(err):
854-
// Soft-fail: the RG was deleted out from under
855-
// this RD. Skip the level rather than refuse to
856-
// dispatch — the rest of the hierarchy still
857-
// produces a usable .res.
858-
default:
859-
return nil, err
860-
}
861-
}
862-
}
863-
864-
// Resolve typed options through the same scope hierarchy as the
865-
// legacy Props resolver. Lower scope wins per non-nil field.
866-
// Phase 10.4: ControllerConfig.Spec.DRBDOptions is the typed
867-
// controller scope. The legacy KVEntry-shaped controllerProps
868-
// remains for forward-compat with any pre-migration cluster.
869-
typed := drbd.ResolveDRBDOptions(ctrlTyped, rgTyped, rdTyped, target.Spec.DRBDOptions)
870-
871-
// Flatten typed → string Props so the existing satellite-side
872-
// renderer keeps working. Then layer on the legacy Props
873-
// hierarchy (controller → RG → RD → Resource) so any keys still
874-
// living on Spec.Props (residual non-DRBD or pre-migration data)
875-
// or in ExtraProps still feed into the .res file. Lower scope
876-
// wins on conflict, matching the typed resolver's semantics.
877-
out := drbd.ResolveOptions(controllerProps, rgProps, rdProps, target.Spec.Props)
878-
879-
maps.Copy(out, drbd.TypedDRBDOptionsToProps(typed))
880-
maps.Copy(out, ctrlExtras)
881-
maps.Copy(out, rgExtras)
882-
maps.Copy(out, rdExtras)
883-
maps.Copy(out, target.Spec.ExtraProps)
884-
885-
return out, nil
886-
}
887-
888-
// controllerConfig fetches the singleton ControllerConfig CRD. We
889-
// only recognise the canonical name `default`; any other instance
890-
// is silently ignored so a stale or duplicated ControllerConfig
891-
// can't quietly take effect. Missing CRD or missing object both
892-
// return (nil, nil) — the reconciler falls through to legacy
893-
// KVEntry-based controllerProps for forward-compat with
894-
// pre-migration clusters.
895-
func (r *ResourceReconciler) controllerConfig(ctx context.Context) (*blockstoriov1alpha1.ControllerConfig, error) {
896-
var cfg blockstoriov1alpha1.ControllerConfig
897-
898-
err := r.Get(ctx, client.ObjectKey{Name: blockstoriov1alpha1.ControllerConfigName}, &cfg)
899-
if err != nil {
900-
if errors.IsNotFound(err) {
901-
return nil, nil //nolint:nilnil // optional singleton — nil means "not configured"
902-
}
903-
904-
return nil, err
905-
}
906-
907-
return &cfg, nil
805+
return effectiveprops.Resolve(ctx, r.Client, target, rdPtr)
908806
}
909807

910808
// resolveLayerStack walks the RD → RG hierarchy and returns the
@@ -933,35 +831,6 @@ func (r *ResourceReconciler) resolveLayerStack(ctx context.Context, rd *blocksto
933831
return rg.Spec.SelectFilter.LayerStack
934832
}
935833

936-
// controllerProps reads the cluster-wide ControllerProps KV instance
937-
// via the KVEntry CRD. Empty when no entries exist (fresh cluster).
938-
//
939-
// We list every KVEntry and filter by Instance in-process; an
940-
// indexed `client.MatchingFields{"spec.instance": "ControllerProps"}`
941-
// would be cheaper but the controller-runtime cache rejects field
942-
// selectors that aren't pre-registered as field indexers, and the
943-
// extra wiring isn't worth it for a small KV store.
944-
func (r *ResourceReconciler) controllerProps(ctx context.Context) (map[string]string, error) {
945-
var list blockstoriov1alpha1.KVEntryList
946-
947-
err := r.List(ctx, &list)
948-
if err != nil {
949-
return nil, err
950-
}
951-
952-
out := map[string]string{}
953-
954-
for i := range list.Items {
955-
if list.Items[i].Spec.Instance != "ControllerProps" {
956-
continue
957-
}
958-
959-
out[list.Items[i].Spec.Key] = list.Items[i].Spec.Value
960-
}
961-
962-
return out, nil
963-
}
964-
965834
// EnsureDRBDIDsForTest is an exported alias for ensureDRBDIDs. The
966835
// allocator is package-private because it's an internal reconciler
967836
// step, but the property tests live in package controller_test and

pkg/effectiveprops/effective.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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 effectiveprops resolves the DRBD-options bag for one
18+
// Resource by walking the upstream-LINSTOR override hierarchy:
19+
// Controller → ResourceGroup → ResourceDefinition → Resource.
20+
//
21+
// Lower scopes override upper, per non-nil field. Each scope is
22+
// best-effort — a missing ControllerConfig / missing RG /
23+
// missing RD degrades to "empty" rather than blocking the
24+
// dispatch, so a partially-migrated cluster still produces a
25+
// usable .res file.
26+
//
27+
// Lifted out of `internal/controller.ResourceReconciler` in
28+
// Phase 10.1 so both the controller-side dispatcher AND the new
29+
// satellite-side `pkg/satellite/controllers.ResourceReconciler`
30+
// share one implementation.
31+
package effectiveprops
32+
33+
import (
34+
"context"
35+
"maps"
36+
37+
"github.com/cockroachdb/errors"
38+
apierrors "k8s.io/apimachinery/pkg/api/errors"
39+
"sigs.k8s.io/controller-runtime/pkg/client"
40+
41+
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
42+
"github.com/cozystack/blockstor/pkg/drbd"
43+
)
44+
45+
// LegacyControllerPropsInstance is the KVEntry-instance name
46+
// the pre-Phase-10.4 `ControllerProps` used. Read-only fallback
47+
// — once every cluster has migrated to `ControllerConfig` this
48+
// path stops firing and the KVEntry CRD can go.
49+
const LegacyControllerPropsInstance = "ControllerProps"
50+
51+
// Resolve walks the four scopes and returns the merged Props
52+
// map. The `c` reader is typically a controller-runtime
53+
// `manager.GetClient()` — both controller and satellite hold
54+
// one. `target` is the Resource whose effective props we want;
55+
// `rd` is the parent ResourceDefinition (may be nil; the
56+
// reconciler usually fetched it already).
57+
//
58+
// Phase 10.1 step.
59+
func Resolve(ctx context.Context, c client.Reader, target *blockstoriov1alpha1.Resource, rd *blockstoriov1alpha1.ResourceDefinition) (map[string]string, error) {
60+
if target == nil {
61+
return map[string]string{}, nil
62+
}
63+
64+
controllerProps, err := LegacyControllerProps(ctx, c)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
ctrlCfg, err := controllerConfig(ctx, c)
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
var (
75+
ctrlTyped *blockstoriov1alpha1.DRBDOptions
76+
ctrlExtras map[string]string
77+
)
78+
79+
if ctrlCfg != nil {
80+
ctrlTyped = ctrlCfg.Spec.DRBDOptions
81+
ctrlExtras = ctrlCfg.Spec.ExtraProps
82+
}
83+
84+
rgInfo, rdInfo, err := scopeInputs(ctx, c, rd)
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
typed := drbd.ResolveDRBDOptions(ctrlTyped, rgInfo.Typed, rdInfo.Typed, target.Spec.DRBDOptions)
90+
91+
out := drbd.ResolveOptions(controllerProps, rgInfo.Props, rdInfo.Props, target.Spec.Props)
92+
93+
maps.Copy(out, drbd.TypedDRBDOptionsToProps(typed))
94+
maps.Copy(out, ctrlExtras)
95+
maps.Copy(out, rgInfo.Extras)
96+
maps.Copy(out, rdInfo.Extras)
97+
maps.Copy(out, target.Spec.ExtraProps)
98+
99+
return out, nil
100+
}
101+
102+
// scopeInputs gathers the RG + RD scope inputs the hierarchy
103+
// resolver needs. Returns zero-valued info structs for missing
104+
// scopes — a missing RG / RD softly degrades to "no input at
105+
// this level" rather than blocking dispatch.
106+
type scopeInfo struct {
107+
Props map[string]string
108+
Typed *blockstoriov1alpha1.DRBDOptions
109+
Extras map[string]string
110+
}
111+
112+
func scopeInputs(ctx context.Context, c client.Reader, rd *blockstoriov1alpha1.ResourceDefinition) (scopeInfo, scopeInfo, error) {
113+
var (
114+
rgInfo scopeInfo
115+
rdInfo scopeInfo
116+
)
117+
118+
if rd == nil {
119+
return rgInfo, rdInfo, nil
120+
}
121+
122+
rdInfo = scopeInfo{
123+
Props: rd.Spec.Props,
124+
Typed: rd.Spec.DRBDOptions,
125+
Extras: rd.Spec.ExtraProps,
126+
}
127+
128+
if rd.Spec.ResourceGroupName == "" {
129+
return rgInfo, rdInfo, nil
130+
}
131+
132+
var rgObj blockstoriov1alpha1.ResourceGroup
133+
134+
getErr := c.Get(ctx, client.ObjectKey{Name: rd.Spec.ResourceGroupName}, &rgObj)
135+
switch {
136+
case getErr == nil:
137+
rgInfo = scopeInfo{
138+
Props: rgObj.Spec.Props,
139+
Typed: rgObj.Spec.DRBDOptions,
140+
Extras: rgObj.Spec.ExtraProps,
141+
}
142+
case apierrors.IsNotFound(getErr):
143+
// Soft-fail; see package doc.
144+
default:
145+
return rgInfo, rdInfo, errors.Wrapf(getErr, "get ResourceGroup %q", rd.Spec.ResourceGroupName)
146+
}
147+
148+
return rgInfo, rdInfo, nil
149+
}
150+
151+
// controllerConfig fetches the singleton ControllerConfig CRD.
152+
// Returns (nil, nil) when missing — caller falls through to
153+
// the legacy KVEntry path. Phase 10.4 step 1.
154+
func controllerConfig(ctx context.Context, c client.Reader) (*blockstoriov1alpha1.ControllerConfig, error) {
155+
var cfg blockstoriov1alpha1.ControllerConfig
156+
157+
err := c.Get(ctx, client.ObjectKey{Name: blockstoriov1alpha1.ControllerConfigName}, &cfg)
158+
if err != nil {
159+
if apierrors.IsNotFound(err) {
160+
return nil, nil //nolint:nilnil // optional singleton
161+
}
162+
163+
return nil, errors.Wrap(err, "get ControllerConfig")
164+
}
165+
166+
return &cfg, nil
167+
}
168+
169+
// LegacyControllerProps reads the KVEntry-shaped ControllerProps
170+
// instance and returns its flat `{key: value}` view. Drained as
171+
// more keys migrate to ControllerConfig's typed fields;
172+
// eventually disappears with the KVEntry CRD. Exported so the
173+
// controller_props test can pin the instance-filter invariant
174+
// directly against the package.
175+
func LegacyControllerProps(ctx context.Context, c client.Reader) (map[string]string, error) {
176+
var list blockstoriov1alpha1.KVEntryList
177+
178+
err := c.List(ctx, &list)
179+
if err != nil {
180+
return nil, errors.Wrap(err, "list KVEntries")
181+
}
182+
183+
out := map[string]string{}
184+
185+
for i := range list.Items {
186+
if list.Items[i].Spec.Instance != LegacyControllerPropsInstance {
187+
continue
188+
}
189+
190+
out[list.Items[i].Spec.Key] = list.Items[i].Spec.Value
191+
}
192+
193+
return out, nil
194+
}

0 commit comments

Comments
 (0)