master
go 368 lines 10.7 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package sd
4
5 import (
6 "context"
7 "fmt"
8 "io"
9 "log/slog"
10 "sync"
11
12 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/pipeline"
13 "github.com/netdata/netdata/go/plugins/plugin/agent/policy"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
15 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
17
18 "github.com/netdata/netdata/go/plugins/logger"
19 "github.com/netdata/netdata/go/plugins/pkg/multipath"
20 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
21 )
22
23 type Config struct {
24 ConfigDefaults confgroup.Registry
25 PluginName string
26 RunModePolicy policy.RunModePolicy
27 Out io.Writer
28 ConfDir multipath.MultiPath
29 FnReg functions.Registry
30 Discoverers Registry
31 }
32
33 func NewServiceDiscovery(cfg Config) (*ServiceDiscovery, error) {
34 log := logger.New().With(
35 slog.String("component", "service discovery"),
36 )
37 if cfg.Discoverers == nil {
38 return nil, fmt.Errorf("service discovery discoverer registry is not configured")
39 }
40 out := cfg.Out
41 if out == nil {
42 out = io.Discard
43 }
44
45 d := &ServiceDiscovery{
46 Logger: log,
47 confProv: newConfFileReader(log, cfg.ConfDir),
48 configDefaults: cfg.ConfigDefaults,
49 pluginName: cfg.PluginName,
50 runModePolicy: cfg.RunModePolicy,
51 fnReg: cfg.FnReg,
52 discoverers: cfg.Discoverers,
53 dyncfgApi: dyncfg.NewResponder(netdataapi.New(out)),
54 seen: dyncfg.NewSeenCache[sdConfig](),
55 exposed: dyncfg.NewExposedCache[sdConfig](),
56 dyncfgCh: make(chan dyncfg.Function, 1),
57 }
58 if provider, ok := cfg.FnReg.(interface {
59 TerminalFinalizer() functions.TerminalFinalizer
60 }); ok {
61 d.dyncfgApi.SetTerminalFinalizer(provider.TerminalFinalizer())
62 }
63 d.newPipeline = func(config pipeline.Config) (sdPipeline, error) {
64 return pipeline.New(config, d.newDiscoverersFromRegistry)
65 }
66 d.sdCb = &sdCallbacks{sd: d}
67 d.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[sdConfig]{
68 Logger: d.Logger,
69 API: d.dyncfgApi,
70 Seen: d.seen,
71 Exposed: d.exposed,
72 Callbacks: d.sdCb,
73 WaitKey: func(cfg sdConfig) string {
74 return cfg.PipelineKey()
75 },
76
77 Path: fmt.Sprintf(dyncfgSDPath, cfg.PluginName),
78 EnableFailCode: 422,
79 JobCommands: []dyncfg.Command{
80 dyncfg.CommandSchema,
81 dyncfg.CommandGet,
82 dyncfg.CommandEnable,
83 dyncfg.CommandDisable,
84 dyncfg.CommandUpdate,
85 dyncfg.CommandTest,
86 dyncfg.CommandUserconfig,
87 },
88 })
89
90 return d, nil
91 }
92
93 type (
94 ServiceDiscovery struct {
95 *logger.Logger
96
97 confProv confFileProvider
98
99 configDefaults confgroup.Registry
100 pluginName string
101 runModePolicy policy.RunModePolicy
102 fnReg functions.Registry
103 discoverers Registry
104 dyncfgApi *dyncfg.Responder
105 seen *dyncfg.SeenCache[sdConfig]
106 exposed *dyncfg.ExposedCache[sdConfig]
107 handler *dyncfg.Handler[sdConfig]
108 sdCb *sdCallbacks
109 dyncfgCh chan dyncfg.Function
110 newPipeline func(config pipeline.Config) (sdPipeline, error)
111
112 ctx context.Context
113 mgr *PipelineManager
114 }
115 sdPipeline interface {
116 Run(ctx context.Context, in chan<- []*confgroup.Group)
117 }
118 confFileProvider interface {
119 run(ctx context.Context)
120 configs() chan confFile
121 }
122 )
123
124 // SetDyncfgResponder allows overriding the default responder (e.g., to silence output in tests).
125 func (d *ServiceDiscovery) SetDyncfgResponder(api *dyncfg.Responder) {
126 if api != nil && d.dyncfgApi != nil {
127 api.SetTerminalFinalizer(d.dyncfgApi.TerminalFinalizer())
128 }
129 dyncfg.BindResponder(&d.dyncfgApi, d.handler, api)
130 }
131
132 func (d *ServiceDiscovery) String() string {
133 return "service discovery"
134 }
135
136 func (d *ServiceDiscovery) Run(ctx context.Context, in chan<- []*confgroup.Group) {
137 d.Info("instance is started")
138 defer func() { d.unregisterDyncfgTemplates(); d.Info("instance is stopped") }()
139
140 // Store context for dyncfg commands
141 d.ctx = ctx
142
143 // Create pipeline manager with send function that forwards to output channel
144 // NOTE: Must be created BEFORE registering dyncfg templates, as dyncfg commands use mgr
145 send := func(ctx context.Context, groups []*confgroup.Group) {
146 select {
147 case <-ctx.Done():
148 case in <- groups:
149 }
150 }
151
152 d.mgr = NewPipelineManager(d.Logger, d.newPipeline, send)
153
154 // Register dyncfg templates for discoverer types
155 // NOTE: Must be AFTER mgr creation, as dyncfg commands use mgr
156 d.registerDyncfgTemplates(ctx)
157
158 var wg sync.WaitGroup
159
160 wg.Go(func() { d.confProv.run(ctx) })
161
162 wg.Go(func() { d.run(ctx) })
163
164 wg.Go(func() { d.mgr.RunGracePeriodCleanup(ctx) })
165
166 wg.Wait()
167
168 // Cleanup all pipelines on shutdown
169 d.mgr.StopAll()
170 }
171
172 func (d *ServiceDiscovery) run(ctx context.Context) {
173 for {
174 if d.handler.WaitingForDecision() {
175 step, ok := d.handler.NextWaitDecisionStep(ctx, d.dyncfgCh)
176 if !ok {
177 return
178 }
179 if step.HasCommand {
180 d.dyncfgSeqExec(step.Command)
181 continue
182 }
183 } else {
184 select {
185 case <-ctx.Done():
186 return
187 case cfg := <-d.confProv.configs():
188 if cfg.source == "" {
189 continue
190 }
191 if len(cfg.content) == 0 {
192 d.removePipeline(cfg)
193 } else {
194 d.addPipeline(ctx, cfg)
195 }
196 case fn := <-d.dyncfgCh:
197 d.dyncfgSeqExec(fn)
198 }
199 }
200 }
201 }
202
203 func (d *ServiceDiscovery) removePipeline(conf confFile) {
204 // Collect configs from this source (can't call Remove inside ForEach)
205 var seenCfgs []sdConfig
206 d.seen.ForEach(func(_ string, cfg sdConfig) bool {
207 if cfg.Source() == conf.source {
208 seenCfgs = append(seenCfgs, cfg)
209 }
210 return true
211 })
212
213 if len(seenCfgs) == 0 {
214 return
215 }
216
217 d.Infof("removing %d config(s) from source '%s'", len(seenCfgs), conf.source)
218
219 for _, scfg := range seenCfgs {
220 // Remove from seen/exposed caches if this config is currently tracked.
221 _, ok := d.handler.RemoveDiscoveredConfig(scfg)
222 if !ok {
223 // Not exposed or different config is exposed - skip dyncfg remove
224 continue
225 }
226
227 // This was the exposed config - stop pipeline and remove from dyncfg
228 if d.mgr.IsRunning(scfg.PipelineKey()) {
229 d.mgr.Stop(scfg.PipelineKey())
230 }
231
232 d.handler.NotifyJobRemove(scfg)
233 }
234 }
235
236 func (d *ServiceDiscovery) addPipeline(ctx context.Context, conf confFile) {
237 // Create sdConfig directly from YAML (cleans name for dyncfg compatibility)
238 sourceType := sourceTypeFromPath(conf.source)
239 pipelineKey := pipelineKeyFromSource(conf.source)
240
241 scfg, err := newSDConfigFromYAML(conf.content, conf.source, sourceType, pipelineKey)
242 if err != nil {
243 d.Errorf("failed to unmarshal config from '%s': %v", conf.source, err)
244 return
245 }
246
247 // Check if disabled
248 if disabled, _ := scfg["disabled"].(bool); disabled {
249 d.Infof("pipeline '%s' is disabled in config", scfg.Name())
250 return
251 }
252
253 if scfg.DiscovererType() == "" {
254 d.Errorf("config '%s' has no discoverer configured", conf.source)
255 return
256 }
257 if !d.hasDiscovererType(scfg.DiscovererType()) {
258 if scfg.SourceType() != confgroup.TypeStock {
259 d.Warningf("config '%s' uses unsupported discoverer type '%s', skipping", conf.source, scfg.DiscovererType())
260 }
261 return
262 }
263
264 if scfg.Name() == "" {
265 d.Errorf("config '%s' has no name configured", conf.source)
266 return
267 }
268
269 d.addConfig(ctx, scfg)
270 }
271
272 // addConfig handles adding a config with priority handling.
273 // This is the core logic matching jobmgr pattern.
274 func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
275 // For file sources: One file = one config. If the file previously provided a different config,
276 // remove the old one first. This handles the case where a file config name changes.
277 if scfg.SourceType() != confgroup.TypeDyncfg {
278 d.removeOldConfigsFromSource(scfg.Source(), scfg.ExposedKey())
279 }
280
281 // Always remember discovered configs, even if they are not exposed.
282 d.handler.RememberDiscoveredConfig(scfg)
283
284 // Check if there's an existing exposed config with the same key
285 entry, exists := d.exposed.LookupByKey(scfg.ExposedKey())
286
287 if !exists {
288 // No existing config - expose this one
289 d.handler.AddDiscoveredConfig(scfg, dyncfg.StatusAccepted)
290
291 d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
292 if d.runModePolicy.AutoEnableDiscovered || d.fnReg == nil || d.dyncfgCh == nil {
293 // Auto-enable in terminal mode and tests.
294 // Also auto-enable when no function registry is attached, because
295 // no external enable/disable commands can be delivered.
296 d.autoEnableConfig(scfg)
297 } else {
298 // Wait for netdata to send enable/disable
299 d.handler.WaitForDecision(scfg)
300 }
301 return
302 }
303
304 // Existing config found - apply priority rules
305 sp, ep := scfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
306
307 // Higher priority wins. If same priority and existing is running, keep existing (stability).
308 if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
309 d.Debugf("config '%s': keeping existing (priority: existing=%d new=%d, status=%s)",
310 scfg.ExposedKey(), ep, sp, entry.Status)
311 return
312 }
313
314 // New config wins - stop existing if running
315 d.Infof("config '%s': replacing existing (priority: existing=%d new=%d)", scfg.ExposedKey(), ep, sp)
316
317 if entry.Status == dyncfg.StatusRunning {
318 d.mgr.Stop(entry.Cfg.PipelineKey())
319 }
320
321 // Replace in exposed cache
322 d.handler.AddDiscoveredConfig(scfg, dyncfg.StatusAccepted)
323
324 // Update dyncfg (remove old, create new with new source)
325 d.handler.NotifyJobRemove(entry.Cfg)
326 d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
327
328 if d.runModePolicy.AutoEnableDiscovered || d.fnReg == nil || d.dyncfgCh == nil {
329 d.autoEnableConfig(scfg)
330 } else {
331 d.handler.WaitForDecision(scfg)
332 }
333 }
334
335 // removeOldConfigsFromSource removes configs from the same source that have a different key.
336 // This handles the case where a file's config name changes.
337 // Note: We don't stop the pipeline here - the new config will stop it when it starts via
338 // PipelineManager.Start (which stops any existing pipeline with the same key).
339 // This ensures that if the new config fails to start, the old pipeline keeps running.
340 func (d *ServiceDiscovery) removeOldConfigsFromSource(source, newKey string) {
341 // Collect configs from this source (can't call Remove inside ForEach)
342 var oldCfgs []sdConfig
343 d.seen.ForEach(func(_ string, cfg sdConfig) bool {
344 if cfg.Source() == source {
345 oldCfgs = append(oldCfgs, cfg)
346 }
347 return true
348 })
349
350 for _, oldCfg := range oldCfgs {
351 if oldCfg.ExposedKey() == newKey {
352 continue // Same config, skip
353 }
354
355 // Different config from same source - remove from caches
356 // If it was exposed, remove from exposed cache and dyncfg.
357 // But DON'T stop the pipeline - let the new config's enable handle that
358 if _, ok := d.handler.RemoveDiscoveredConfig(oldCfg); ok {
359 d.handler.NotifyJobRemove(oldCfg)
360 }
361 }
362 }
363
364 // pipelineKeyFromSource extracts a pipeline key from a file source path.
365 // For now, we use the file path as key. This will be extended for dyncfg.
366 func pipelineKeyFromSource(source string) string {
367 return source
368 }