Skip to content

Commit add79d7

Browse files
authored
[addon-operator] fix hook snapshot loss and SIGSEGV on hook re-registration (#798)
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent fe2ea51 commit add79d7

6 files changed

Lines changed: 307 additions & 17 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require (
1010
github.com/dominikbraun/graph v0.23.0
1111
github.com/ettle/strcase v0.2.0
1212
github.com/flant/kube-client v1.7.0
13-
github.com/flant/shell-operator v1.17.6
13+
github.com/flant/shell-operator v1.17.7
1414
github.com/go-chi/chi/v5 v5.3.0
1515
github.com/go-openapi/loads v0.23.1
1616
github.com/go-openapi/spec v0.22.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
156156
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
157157
github.com/flant/kube-client v1.7.0 h1:+B0yZfQuVKnsyf51+pQckxeKA7SR9YOdhyR3GeQi57s=
158158
github.com/flant/kube-client v1.7.0/go.mod h1:16bcsXO8C7i0rtKmrpbgmJpnQgqlUzfIOZs521WmKcE=
159-
github.com/flant/shell-operator v1.17.6 h1:75JyR31JTfiJphuv21nmZh3h+/Dgo9O+A8tfDABrvCk=
160-
github.com/flant/shell-operator v1.17.6/go.mod h1:iAIo8qXxkNsMiabep4EwxWD2Q2b4fqIjXEVS/M9Bw9Q=
159+
github.com/flant/shell-operator v1.17.7 h1:yhYGXasvd/p/gzaYPw47CeGL8n9QZQRZQjQcPiSfRsk=
160+
github.com/flant/shell-operator v1.17.7/go.mod h1:iAIo8qXxkNsMiabep4EwxWD2Q2b4fqIjXEVS/M9Bw9Q=
161161
github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
162162
github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
163163
github.com/fluxcd/flagger v1.36.1 h1:X2PumtNwZz9YSGaOtZLFm2zAKLgHhFkbNv8beg7ifyc=

pkg/module_manager/models/modules/basic.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,14 +247,17 @@ func (bm *BasicModule) DeregisterHooks() {
247247
bm.hasReadiness = false
248248
}
249249

250-
// HooksControllersReady returns controllersReady status of the hook storage
250+
// HooksControllersReady returns controllersReady status of the hook storage.
251+
// Reading under the storage lock also gives a happens-before edge: a reader
252+
// that observed true sees all WithHookController writes made before the flag
253+
// was set.
251254
func (bm *BasicModule) HooksControllersReady() bool {
252-
return bm.hooks.controllersReady
255+
return bm.hooks.isControllersReady()
253256
}
254257

255258
// SetHooksControllersReady sets controllersReady status of the hook storage to true
256259
func (bm *BasicModule) SetHooksControllersReady() {
257-
bm.hooks.controllersReady = true
260+
bm.hooks.setControllersReady()
258261
}
259262

260263
// ResetState drops the module state
@@ -284,7 +287,13 @@ func (bm *BasicModule) ResetState() {
284287

285288
// RegisterHooks searches and registers all module hooks from a filesystem or GoHook Registry
286289
func (bm *BasicModule) RegisterHooks(logger *log.Logger) ([]*hooks.ModuleHook, error) {
287-
if bm.hooks.registered {
290+
// Serialize whole-module registration: two concurrent ModuleRun tasks can
291+
// both reach this point. The loser must wait and take the fast path below
292+
// instead of re-publishing hooks with not-yet-set controllers.
293+
bm.hooks.registrationMu.Lock()
294+
defer bm.hooks.registrationMu.Unlock()
295+
296+
if bm.hooks.isRegistered() {
288297
logger.Debug("Module hooks already registered")
289298
return nil, nil
290299
}
@@ -310,7 +319,7 @@ func (bm *BasicModule) RegisterHooks(logger *log.Logger) ([]*hooks.ModuleHook, e
310319
return nil, fmt.Errorf("register hooks: %w", err)
311320
}
312321

313-
bm.hooks.registered = true
322+
bm.hooks.setRegistered()
314323
bm.hasReadiness = searchModuleHooksResult.HasReadiness
315324

316325
return searchModuleHooksResult.Hooks, nil

pkg/module_manager/models/modules/hook_storage.go

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ import (
1212
type HooksStorage struct {
1313
registered bool
1414
controllersReady bool
15-
lock sync.RWMutex
16-
byBinding map[sh_op_types.BindingType][]*hooks.ModuleHook
17-
byName map[string]*hooks.ModuleHook
15+
// registrationMu serializes whole-module hook registration. Separate from
16+
// lock: registration execs hook binaries and must not hold the index lock
17+
// across that.
18+
registrationMu sync.Mutex
19+
lock sync.RWMutex
20+
byBinding map[sh_op_types.BindingType][]*hooks.ModuleHook
21+
byName map[string]*hooks.ModuleHook
1822
}
1923

2024
func newHooksStorage() *HooksStorage {
@@ -31,12 +35,66 @@ func (hs *HooksStorage) AddHook(hk *hooks.ModuleHook) {
3135

3236
hName := hk.GetName()
3337

38+
// Re-registration (e.g. ModuleRun retry after a transient failure) must
39+
// replace the previous object, not accumulate duplicates: a stale entry
40+
// keeps a nil HookController and crashes the kube-events dispatcher.
41+
if old, ok := hs.byName[hName]; ok {
42+
hs.removeHookFromBindings(old)
43+
}
44+
3445
hs.byName[hName] = hk
3546
for _, binding := range hk.GetHookConfig().Bindings() {
3647
hs.byBinding[binding] = append(hs.byBinding[binding], hk)
3748
}
3849
}
3950

51+
// removeHookFromBindings deletes all byBinding entries pointing to the given
52+
// hook. Call under hs.lock.
53+
func (hs *HooksStorage) removeHookFromBindings(hk *hooks.ModuleHook) {
54+
for binding, hks := range hs.byBinding {
55+
filtered := make([]*hooks.ModuleHook, 0, len(hks))
56+
for _, h := range hks {
57+
if h != hk {
58+
filtered = append(filtered, h)
59+
}
60+
}
61+
62+
if len(filtered) == 0 {
63+
delete(hs.byBinding, binding)
64+
} else {
65+
hs.byBinding[binding] = filtered
66+
}
67+
}
68+
}
69+
70+
func (hs *HooksStorage) isRegistered() bool {
71+
hs.lock.RLock()
72+
defer hs.lock.RUnlock()
73+
74+
return hs.registered
75+
}
76+
77+
func (hs *HooksStorage) setRegistered() {
78+
hs.lock.Lock()
79+
defer hs.lock.Unlock()
80+
81+
hs.registered = true
82+
}
83+
84+
func (hs *HooksStorage) isControllersReady() bool {
85+
hs.lock.RLock()
86+
defer hs.lock.RUnlock()
87+
88+
return hs.controllersReady
89+
}
90+
91+
func (hs *HooksStorage) setControllersReady() {
92+
hs.lock.Lock()
93+
defer hs.lock.Unlock()
94+
95+
hs.controllersReady = true
96+
}
97+
4098
func (hs *HooksStorage) getHooks(bt ...sh_op_types.BindingType) []*hooks.ModuleHook {
4199
hs.lock.RLock()
42100
defer hs.lock.RUnlock()
@@ -49,16 +107,22 @@ func (hs *HooksStorage) getHooks(bt ...sh_op_types.BindingType) []*hooks.ModuleH
49107
return []*hooks.ModuleHook{}
50108
}
51109

52-
sort.Slice(res, func(i, j int) bool {
53-
oi, oj := res[i].Order(t), res[j].Order(t)
110+
// Sort a copy: the shared slice must not be mutated under RLock, and
111+
// callers must not observe later index updates through the returned
112+
// header.
113+
out := make([]*hooks.ModuleHook, len(res))
114+
copy(out, res)
115+
116+
sort.Slice(out, func(i, j int) bool {
117+
oi, oj := out[i].Order(t), out[j].Order(t)
54118
if oi != oj {
55119
return oi < oj
56120
}
57121

58-
return res[i].GetName() < res[j].GetName()
122+
return out[i].GetName() < out[j].GetName()
59123
})
60124

61-
return res
125+
return out
62126
}
63127

64128
// return all hooks
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package modules
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"testing"
10+
11+
"github.com/deckhouse/deckhouse/pkg/log"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
15+
16+
gohook "github.com/flant/addon-operator/pkg/module_manager/go_hook"
17+
"github.com/flant/addon-operator/pkg/module_manager/models/hooks"
18+
"github.com/flant/addon-operator/pkg/module_manager/models/hooks/kind"
19+
"github.com/flant/addon-operator/pkg/utils"
20+
"github.com/flant/shell-operator/pkg/hook/config"
21+
"github.com/flant/shell-operator/pkg/hook/controller"
22+
sh_op_types "github.com/flant/shell-operator/pkg/hook/types"
23+
)
24+
25+
// newKubeGoHook returns a go hook with a single OnKubernetesEvent binding.
26+
// Config is precompiled, so no binary is executed on InitializeHookConfig.
27+
func newKubeGoHook(name string) *kind.GoHook {
28+
gh := kind.NewGoHook(&gohook.HookConfig{
29+
Kubernetes: []gohook.KubernetesConfig{
30+
{
31+
Name: "pods",
32+
ApiVersion: "v1",
33+
Kind: "Pod",
34+
FilterFunc: func(_ *unstructured.Unstructured) (gohook.FilterResult, error) {
35+
return nil, nil
36+
},
37+
},
38+
},
39+
Logger: log.NewNop(),
40+
}, func(_ context.Context, _ *gohook.HookInput) error { return nil })
41+
42+
gh.AddMetadata(&gohook.HookMetadata{Name: name, Path: "/hooks/" + name})
43+
44+
return gh
45+
}
46+
47+
// newInitializedModuleHook wraps a go hook into ModuleHook and loads its config,
48+
// so GetHookConfig().Bindings() returns OnKubernetesEvent.
49+
func newInitializedModuleHook(t *testing.T, name string) *hooks.ModuleHook {
50+
t.Helper()
51+
52+
mh := hooks.NewModuleHook(newKubeGoHook(name))
53+
require.NoError(t, mh.InitializeHookConfig())
54+
55+
return mh
56+
}
57+
58+
// TestAddHookIsIdempotentPerBinding proves the byBinding duplication bug:
59+
// re-registering a hook with the same name must replace the old entry,
60+
// not append a second one (Trigger A root cause).
61+
func TestAddHookIsIdempotentPerBinding(t *testing.T) {
62+
hs := newHooksStorage()
63+
64+
first := newInitializedModuleHook(t, "hooks/license")
65+
// A retry creates a fresh object for the same hook (searchModuleHooks
66+
// calls NewModuleHook on every attempt).
67+
second := newInitializedModuleHook(t, "hooks/license")
68+
69+
hs.AddHook(first)
70+
hs.AddHook(second)
71+
72+
byName := hs.getHookByName("hooks/license")
73+
require.Same(t, second, byName, "byName keeps the most recent object")
74+
75+
byBinding := hs.getHooks(sh_op_types.OnKubernetesEvent)
76+
require.Len(t, byBinding, 1,
77+
"byBinding must hold exactly one entry per hook name after re-registration")
78+
require.Same(t, second, byBinding[0],
79+
"byBinding entry must be the most recently registered object")
80+
}
81+
82+
// flakyGoHook fails config loading a given number of times.
83+
// Simulates the transient exec failure (ECHILD) that aborted hook
84+
// registration mid-loop in production.
85+
type flakyGoHook struct {
86+
*kind.GoHook
87+
88+
mu sync.Mutex
89+
failures int
90+
}
91+
92+
func (f *flakyGoHook) GetConfigForModule(moduleKind string) (*config.HookConfig, error) {
93+
f.mu.Lock()
94+
defer f.mu.Unlock()
95+
96+
if f.failures > 0 {
97+
f.failures--
98+
return nil, errors.New("exec file '/hooks/go-hooks-bin': waitid: no child processes")
99+
}
100+
101+
return f.GoHook.GetConfigForModule(moduleKind)
102+
}
103+
104+
// TestRegistrationRetryLeavesNoOrphanHooks reproduces the production incident:
105+
// attempt 1 publishes some hooks and fails mid-loop, attempt 2 re-registers
106+
// fresh objects and attaches controllers only to them. Every hook visible via
107+
// GetHooks must have a controller afterwards - a nil one is exactly the value
108+
// that crashed the operator with SIGSEGV.
109+
func TestRegistrationRetryLeavesNoOrphanHooks(t *testing.T) {
110+
logger := log.NewNop()
111+
112+
bm, err := NewBasicModule("flant-integration", t.TempDir(), 1, utils.Values{}, nil, nil)
113+
require.NoError(t, err)
114+
bm.WithDependencies(stubDeps(logger))
115+
116+
// Attempt 1: the second hook fails on config load, registerHooks aborts.
117+
// The first hook is already published to byBinding without a controller.
118+
okHook1 := hooks.NewModuleHook(newKubeGoHook("hooks/ok"))
119+
failingHook1 := hooks.NewModuleHook(&flakyGoHook{GoHook: newKubeGoHook("hooks/license"), failures: 1})
120+
121+
err = bm.registerHooks([]*hooks.ModuleHook{okHook1, failingHook1}, logger)
122+
require.Error(t, err, "attempt 1 must fail on the failing hook")
123+
124+
// Attempt 2: ModuleRun retry - fresh hook objects, the failure is gone.
125+
okHook2 := hooks.NewModuleHook(newKubeGoHook("hooks/ok"))
126+
failingHook2 := hooks.NewModuleHook(&flakyGoHook{GoHook: newKubeGoHook("hooks/license")})
127+
128+
err = bm.registerHooks([]*hooks.ModuleHook{okHook2, failingHook2}, logger)
129+
require.NoError(t, err, "attempt 2 must succeed")
130+
131+
// RegisterModuleHooks attaches controllers only to hooks returned by the
132+
// successful attempt, then raises the readiness flag.
133+
for _, hk := range []*hooks.ModuleHook{okHook2, failingHook2} {
134+
hk.WithHookController(controller.NewHookController())
135+
}
136+
bm.SetHooksControllersReady()
137+
138+
// Invariant: controllersReady == true implies every hook in byBinding has
139+
// a controller.
140+
kubeHooks := bm.GetHooks(sh_op_types.OnKubernetesEvent)
141+
for _, hk := range kubeHooks {
142+
assert.NotNilf(t, hk.GetHookController(),
143+
"hook %q has no controller: stale duplicate left by failed attempt 1", hk.GetName())
144+
}
145+
require.Len(t, kubeHooks, 2,
146+
"byBinding must contain one entry per hook, without stale duplicates")
147+
}
148+
149+
// TestControllersReadyFlagIsRaceFree proves the data race on controllersReady:
150+
// the flag is written by the registration goroutine and read by the kube-events
151+
// dispatcher without synchronization. Run with -race.
152+
func TestControllersReadyFlagIsRaceFree(t *testing.T) {
153+
bm, err := NewBasicModule("race-flag", t.TempDir(), 1, utils.Values{}, nil, nil)
154+
require.NoError(t, err)
155+
156+
var wg sync.WaitGroup
157+
wg.Add(2)
158+
159+
go func() {
160+
defer wg.Done()
161+
for range 1000 {
162+
bm.SetHooksControllersReady()
163+
}
164+
}()
165+
166+
go func() {
167+
defer wg.Done()
168+
for range 1000 {
169+
_ = bm.HooksControllersReady()
170+
}
171+
}()
172+
173+
wg.Wait()
174+
require.True(t, bm.HooksControllersReady())
175+
}
176+
177+
// TestConcurrentRegisterHooksIsSafe proves Trigger B entry: two ModuleRun
178+
// workers can both pass the `registered` check before either sets it, so the
179+
// module is registered twice (duplicates + data race on the flag). Run with -race.
180+
func TestConcurrentRegisterHooksIsSafe(t *testing.T) {
181+
moduleDir := t.TempDir()
182+
hooksDir := filepath.Join(moduleDir, "hooks")
183+
require.NoError(t, os.MkdirAll(hooksDir, 0o755))
184+
require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "startup.sh"), []byte(`#!/bin/sh
185+
if [ "$1" = "--config" ]; then
186+
echo '{"configVersion":"v1","onStartup":10}'
187+
exit 0
188+
fi
189+
exit 0
190+
`), 0o755))
191+
192+
logger := log.NewNop()
193+
194+
bm, err := NewBasicModule("concurrent", moduleDir, 1, utils.Values{}, nil, nil)
195+
require.NoError(t, err)
196+
bm.WithDependencies(stubDeps(logger))
197+
198+
var wg sync.WaitGroup
199+
for range 2 {
200+
wg.Add(1)
201+
go func() {
202+
defer wg.Done()
203+
_, _ = bm.RegisterHooks(logger)
204+
}()
205+
}
206+
wg.Wait()
207+
208+
require.Len(t, bm.GetHooks(sh_op_types.OnStartup), 1,
209+
"concurrent registration must not produce duplicate hooks")
210+
}

0 commit comments

Comments
 (0)