Skip to content

Commit 2186960

Browse files
kvapsclaude
andcommitted
feat(satellite): Observer translates drbd.Event into ResourceObservedEvent
Stateless mapping from parsed events2 lines to the proto observation type. Lets the satellite ship runtime state back to the controller without leaking the events2 wire format upward. RPC plumbing follows in the next slice. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 6dbf303 commit 2186960

3 files changed

Lines changed: 272 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
228228
- [x] `drbdadm up/down/adjust/create-md/primary/secondary` exec wrappers behind interface (`pkg/drbd/drbdadm.go`); 7 contract tests via FakeExec
229229
- [x] `drbdsetup events2` listener (`pkg/drbd/events2.go`): line parser + Watcher streaming `Event{Action,Kind,Fields}` to a channel; 7 contract tests
230230
- [x] Resource reconciler (`pkg/satellite.Reconciler`) routes DesiredResource batches: storage provider CreateVolume per volume, ConfFileBuilder writes /etc/drbd.d/<name>.res, drbdadm create-md (first activation, non-DISKLESS) + adjust. Status writeback from events2 stream is the next slice.
231-
- [ ] Status writeback: events2 listener pushes ResourceObservedEvent stream back to controller
231+
- [x] Status writeback first half: `pkg/satellite.Observer` translates parsed drbd.Event values into ResourceObservedEvent (4 contract tests). Wire-up to gRPC stream pending the controller-side handler.
232232
- [ ] Resource on 2 nodes replicates and goes UpToDate (real DRBD smoke)
233233

234234
**Exit**: smoke test with two replicas, real DRBD, PVC mounted on node A then on node B (failover).

pkg/satellite/observer.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 satellite
18+
19+
import (
20+
"strconv"
21+
"time"
22+
23+
"github.com/cozystack/blockstor/pkg/drbd"
24+
satellitepb "github.com/cozystack/blockstor/pkg/satellite/proto"
25+
)
26+
27+
// Observation is the satellite-side view of one DRBD state change.
28+
// It is a thin wrapper around the proto message so the rest of the
29+
// satellite code can pass observations around without depending on
30+
// generated gRPC types directly.
31+
type Observation = satellitepb.ResourceObservedEvent
32+
33+
// Observer translates parsed `drbdsetup events2` lines into the proto
34+
// observations the controller consumes. It is stateless across events
35+
// — every output is derived from one input — which keeps the wire
36+
// format simple and lets the controller dedupe/coalesce on its side.
37+
type Observer struct {
38+
nodeName string
39+
now func() time.Time
40+
}
41+
42+
// NewObserver constructs an Observer that stamps `time.Now()` into
43+
// every emitted observation.
44+
func NewObserver(nodeName string) *Observer {
45+
return &Observer{nodeName: nodeName, now: time.Now}
46+
}
47+
48+
// Translate consumes events from in, emits observations on the
49+
// returned channel, and closes the output when in is closed. The
50+
// goroutine exits when in closes; callers wanting cancellation should
51+
// close in themselves.
52+
func (o *Observer) Translate(in <-chan drbd.Event) <-chan *Observation {
53+
out := make(chan *Observation)
54+
55+
go func() {
56+
defer close(out)
57+
58+
for ev := range in {
59+
obs, ok := o.translate(ev)
60+
if !ok {
61+
continue
62+
}
63+
64+
out <- obs
65+
}
66+
}()
67+
68+
return out
69+
}
70+
71+
// translate is the core mapping. Returns ok=false for events we
72+
// shouldn't surface (wrong kind, missing resource name, etc.).
73+
func (o *Observer) translate(ev drbd.Event) (*Observation, bool) {
74+
if ev.Kind != "device" {
75+
return nil, false
76+
}
77+
78+
name := ev.Fields["name"]
79+
if name == "" {
80+
return nil, false
81+
}
82+
83+
disk := ev.Fields["disk"]
84+
85+
obs := &Observation{
86+
ResourceName: name,
87+
NodeName: o.nodeName,
88+
DrbdState: disk,
89+
TimestampUnix: o.now().Unix(),
90+
}
91+
92+
volStr, hasVol := ev.Fields["volume"]
93+
if hasVol {
94+
volNum, err := strconv.Atoi(volStr)
95+
if err == nil {
96+
obs.Volumes = []*satellitepb.VolumeObservation{{
97+
VolumeNumber: int32(volNum), //nolint:gosec // volume numbers are small (<32) per drbd-9 limits
98+
DiskState: disk,
99+
}}
100+
}
101+
}
102+
103+
return obs, true
104+
}

pkg/satellite/observer_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 satellite_test
18+
19+
import (
20+
"testing"
21+
22+
"github.com/cozystack/blockstor/pkg/drbd"
23+
"github.com/cozystack/blockstor/pkg/satellite"
24+
)
25+
26+
// TestObserverDeviceEventEmitsState: a `change device disk:UpToDate`
27+
// event surfaces a ResourceObservedEvent with drbd_state populated.
28+
func TestObserverDeviceEventEmitsState(t *testing.T) {
29+
obs := satellite.NewObserver("n1")
30+
31+
events := []drbd.Event{
32+
{
33+
Action: "change",
34+
Kind: "device",
35+
Fields: map[string]string{
36+
"name": "pvc-1",
37+
"volume": "0",
38+
"disk": "UpToDate",
39+
},
40+
},
41+
}
42+
43+
out := collectObservations(obs, events)
44+
45+
if len(out) != 1 {
46+
t.Fatalf("len(out): got %d, want 1", len(out))
47+
}
48+
49+
if got := out[0].GetResourceName(); got != "pvc-1" {
50+
t.Errorf("ResourceName: got %q", got)
51+
}
52+
53+
if got := out[0].GetNodeName(); got != "n1" {
54+
t.Errorf("NodeName: got %q", got)
55+
}
56+
57+
if got := out[0].GetDrbdState(); got != "UpToDate" {
58+
t.Errorf("DrbdState: got %q", got)
59+
}
60+
}
61+
62+
// TestObserverIgnoresUnrelatedKinds: connection / peer-device kinds
63+
// don't carry per-resource disk state, so they don't drive observations.
64+
func TestObserverIgnoresUnrelatedKinds(t *testing.T) {
65+
obs := satellite.NewObserver("n1")
66+
67+
events := []drbd.Event{
68+
{
69+
Action: "change",
70+
Kind: "connection",
71+
Fields: map[string]string{
72+
"name": "pvc-1",
73+
"connection": "Connected",
74+
},
75+
},
76+
{
77+
Action: "exists",
78+
Kind: "-",
79+
Fields: map[string]string{},
80+
},
81+
}
82+
83+
out := collectObservations(obs, events)
84+
85+
if len(out) != 0 {
86+
t.Errorf("expected no observations; got %v", out)
87+
}
88+
}
89+
90+
// TestObserverDropsEventsWithoutResource: a malformed event that lacks a
91+
// `name` field can't be matched to a resource — silently skip.
92+
func TestObserverDropsEventsWithoutResource(t *testing.T) {
93+
obs := satellite.NewObserver("n1")
94+
95+
events := []drbd.Event{
96+
{
97+
Action: "change",
98+
Kind: "device",
99+
Fields: map[string]string{
100+
"disk": "UpToDate",
101+
},
102+
},
103+
}
104+
105+
out := collectObservations(obs, events)
106+
107+
if len(out) != 0 {
108+
t.Errorf("expected no observations; got %v", out)
109+
}
110+
}
111+
112+
// TestObserverIncludesVolumeObservation: device events carry volume
113+
// number — surface it in the VolumeObservation.
114+
func TestObserverIncludesVolumeObservation(t *testing.T) {
115+
obs := satellite.NewObserver("n1")
116+
117+
events := []drbd.Event{
118+
{
119+
Action: "change",
120+
Kind: "device",
121+
Fields: map[string]string{
122+
"name": "pvc-1",
123+
"volume": "0",
124+
"disk": "UpToDate",
125+
},
126+
},
127+
}
128+
129+
out := collectObservations(obs, events)
130+
131+
if len(out) != 1 {
132+
t.Fatalf("len(out): got %d", len(out))
133+
}
134+
135+
vols := out[0].GetVolumes()
136+
if len(vols) != 1 {
137+
t.Fatalf("len(volumes): got %d, want 1", len(vols))
138+
}
139+
140+
if got := vols[0].GetVolumeNumber(); got != 0 {
141+
t.Errorf("VolumeNumber: got %d", got)
142+
}
143+
144+
if got := vols[0].GetDiskState(); got != "UpToDate" {
145+
t.Errorf("DiskState: got %q", got)
146+
}
147+
}
148+
149+
// collectObservations is a shared helper to drain Observer.Observe over
150+
// a fixed input slice. We push into the input channel, close it, then
151+
// read everything off the output channel.
152+
func collectObservations(obs *satellite.Observer, events []drbd.Event) []*satellite.Observation {
153+
in := make(chan drbd.Event, len(events))
154+
for _, e := range events {
155+
in <- e
156+
}
157+
158+
close(in)
159+
160+
var out []*satellite.Observation
161+
162+
for ev := range obs.Translate(in) {
163+
out = append(out, ev)
164+
}
165+
166+
return out
167+
}

0 commit comments

Comments
 (0)