master
go 530 lines 14.9 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package funcctl
4
5 import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "slices"
10 "strings"
11
12 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
15 )
16
17 const (
18 paramJob = "__job"
19 )
20
21 type methodParamResolver func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error)
22
23 type methodExecutionInput struct {
24 fn functions.Function
25 moduleName string
26 jobName string
27 jobLabel string
28 methodID string
29 methodCfg *funcapi.MethodConfig
30 job collectorapi.RuntimeJob
31 jobGen uint64
32 payload map[string]any
33 argValues map[string][]string
34 resolveParams methodParamResolver
35 augmentParams func(funcapi.ResolvedParams)
36 respond methodResponseWriter
37 }
38
39 func (c *Controller) ExecuteFunction(functionName string, fn functions.Function) {
40 moduleName, methodID, err := functions.SplitFunctionName(functionName)
41 if err != nil {
42 c.respondError(fn, 400, "%v", err)
43 return
44 }
45
46 c.makeMethodFuncHandler(moduleName, methodID)(fn)
47 }
48
49 func (c *Controller) executeMethodRequest(in methodExecutionInput) {
50 ctx, cancel := context.WithTimeout(c.baseContext(), in.fn.Timeout)
51 defer cancel()
52
53 if !in.job.IsRunning() {
54 c.respondError(in.fn, 503, "job '%s' is no longer running", in.jobLabel)
55 return
56 }
57
58 creator, ok := c.registry.getCreator(in.moduleName)
59 if !ok || creator.MethodHandler == nil {
60 c.respondError(in.fn, 500, "module '%s' does not implement MethodHandler", in.moduleName)
61 return
62 }
63
64 handler := creator.MethodHandler(in.job)
65 if handler == nil {
66 c.respondError(in.fn, 500, "module '%s' returned nil handler for job '%s'", in.moduleName, in.jobName)
67 return
68 }
69
70 methodParams, paramsFromJob, err := in.resolveParams(ctx, in.methodCfg, handler, in.methodID)
71 if err != nil {
72 c.respondError(in.fn, 503, "job '%s' cannot provide parameters: %v", in.jobLabel, err)
73 return
74 }
75
76 if paramsFromJob {
77 if err := validateParamValues(methodParams, in.argValues, in.payload, in.jobName); err != nil {
78 c.respondError(in.fn, 400, "%v", err)
79 return
80 }
81 }
82
83 methodParamValues := make(map[string][]string, len(methodParams))
84 for _, paramCfg := range methodParams {
85 methodParamValues[paramCfg.ID] = paramValues(in.argValues, in.payload, paramCfg.ID)
86 }
87 resolvedParams := funcapi.ResolveParams(methodParams, methodParamValues)
88 if in.augmentParams != nil {
89 in.augmentParams(resolvedParams)
90 }
91
92 dataResp := handler.Handle(ctx, in.methodID, resolvedParams)
93
94 if !c.registry.verifyJobGeneration(in.moduleName, in.jobName, in.jobGen) {
95 c.respondError(in.fn, 503, "job '%s' was replaced during request, please retry", in.jobLabel)
96 return
97 }
98
99 updateEvery := max(in.methodCfg.UpdateEvery, 1)
100
101 in.respond(dataResp, methodParams, updateEvery)
102 }
103
104 func (c *Controller) makeMethodFuncHandler(moduleName, methodID string) func(functions.Function) {
105 return func(fn functions.Function) {
106 if slices.Contains(fn.Args, "info") {
107 c.handleMethodFuncInfo(moduleName, methodID, fn)
108 return
109 }
110
111 methodCfg, ok := c.registry.getMethod(moduleName, methodID)
112 if !ok {
113 c.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
114 return
115 }
116
117 payload := parsePayload(fn.Payload)
118 argValues := parseArgsParams(fn.Args)
119 includeJobParam := methodRequiresJobParam(methodCfg)
120
121 jobs := c.registry.getJobNames(moduleName)
122 if len(jobs) == 0 {
123 c.respondError(fn, 422, "no %s instances configured", moduleName)
124 return
125 }
126
127 // FIXME: AgentWide currently means "omit __job from the public API" rather
128 // than "dispatch without a job"; we still route through the first running
129 // job for the module.
130 jobName := jobs[0]
131 var resolvedJob funcapi.ResolvedParam
132 if includeJobParam {
133 jobParam := buildJobParamConfig(jobs)
134 jobValues := paramValues(argValues, payload, paramJob)
135 if len(jobValues) > 1 {
136 c.respondError(fn, 400, "parameter '%s' expects a single value", paramJob)
137 return
138 }
139 resolvedJob = funcapi.ResolveParam(jobParam, jobValues)
140 jobName = resolvedJob.GetOne()
141 if len(jobValues) > 0 && jobValues[0] != jobName {
142 c.respondError(fn, 404, "unknown job '%s', available: %v", jobValues[0], jobs)
143 return
144 }
145 if jobName == "" {
146 c.respondError(fn, 404, "no %s instances configured", moduleName)
147 return
148 }
149 }
150
151 job, jobGen := c.registry.getJobWithGeneration(moduleName, jobName)
152 if job == nil {
153 c.respondError(fn, 404, "unknown job '%s', available: %v", jobName, jobs)
154 return
155 }
156
157 c.executeMethodRequest(methodExecutionInput{
158 fn: fn,
159 moduleName: moduleName,
160 jobName: jobName,
161 jobLabel: jobName,
162 methodID: methodID,
163 methodCfg: methodCfg,
164 job: job,
165 jobGen: jobGen,
166 payload: payload,
167 argValues: argValues,
168 resolveParams: func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
169 return c.resolveMethodParamsForJob(ctx, methodCfg, job, handler, methodID)
170 },
171 augmentParams: func(resolvedParams funcapi.ResolvedParams) {
172 resolvedParams[paramJob] = resolvedJob
173 },
174 respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
175 c.respondWithParams(fn, moduleName, dataResp, methodParams, updateEvery, methodCfg.ResponseType, includeJobParam)
176 },
177 })
178 }
179 }
180
181 func (c *Controller) handleMethodFuncInfo(moduleName, methodID string, fn functions.Function) {
182 methodCfg, ok := c.registry.getMethod(moduleName, methodID)
183 if !ok {
184 c.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
185 return
186 }
187
188 methodParams := methodCfg.RequiredParams
189 includeJobParam := methodRequiresJobParam(methodCfg)
190 help := methodCfg.Help
191 if help == "" {
192 help = fmt.Sprintf("%s %s data function", moduleName, methodID)
193 }
194
195 updateEvery := max(methodCfg.UpdateEvery, 1)
196
197 resp := map[string]any{
198 "v": 3,
199 "update_every": updateEvery,
200 "status": 200,
201 "type": resolveResponseType("", methodCfg.ResponseType),
202 "has_history": false,
203 "help": help,
204 "accepted_params": buildAcceptedParams(methodParams, includeJobParam),
205 "required_params": c.buildRequiredParams(moduleName, methodParams, includeJobParam),
206 }
207
208 if presentation := methodCfg.Presentation(); presentation != nil {
209 resp["presentation"] = presentation
210 }
211
212 c.respondJSON(fn, resp)
213 }
214
215 func (c *Controller) buildRequiredParams(moduleName string, methodParams []funcapi.ParamConfig, includeJobParam bool) []map[string]any {
216 paramConfigs := make([]funcapi.ParamConfig, 0, len(methodParams)+1)
217 if includeJobParam {
218 jobs := c.registry.getJobNames(moduleName)
219 paramConfigs = append(paramConfigs, buildJobParamConfig(jobs))
220 }
221 paramConfigs = append(paramConfigs, methodParams...)
222
223 required := make([]map[string]any, 0, len(paramConfigs))
224 for _, cfg := range paramConfigs {
225 required = append(required, cfg.RequiredParam())
226 }
227 return required
228 }
229
230 func (c *Controller) resolveMethodParamsForJob(ctx context.Context, methodCfg *funcapi.MethodConfig, job collectorapi.RuntimeJob, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
231 methodParams := methodCfg.RequiredParams
232
233 jobParams, err := handler.MethodParams(ctx, methodID)
234 if err != nil {
235 return nil, false, err
236 }
237 if len(jobParams) == 0 {
238 return methodParams, true, nil
239 }
240
241 return funcapi.MergeParamConfigs(methodParams, jobParams), true, nil
242 }
243
244 func validateParamValues(methodParams []funcapi.ParamConfig, argValues map[string][]string, payload map[string]any, jobName string) error {
245 for _, cfg := range methodParams {
246 values := paramValues(argValues, payload, cfg.ID)
247 if len(values) == 0 {
248 continue
249 }
250 if cfg.Selection == funcapi.ParamSelect && len(values) > 1 {
251 return fmt.Errorf("parameter '%s' expects a single value for job '%s'", cfg.ID, jobName)
252 }
253 allowed := allowedOptions(cfg.Options)
254 for _, value := range values {
255 if !allowed[value] {
256 return fmt.Errorf("parameter '%s' option '%s' is not supported by job '%s'", cfg.ID, value, jobName)
257 }
258 }
259 }
260 return nil
261 }
262
263 func allowedOptions(options []funcapi.ParamOption) map[string]bool {
264 allowed := make(map[string]bool, len(options))
265 for _, opt := range options {
266 if opt.ID == "" || opt.Disabled {
267 continue
268 }
269 allowed[opt.ID] = true
270 }
271 return allowed
272 }
273
274 func parsePayload(raw []byte) map[string]any {
275 if len(raw) == 0 {
276 return nil
277 }
278
279 var payload map[string]any
280 if err := json.Unmarshal(raw, &payload); err != nil {
281 return nil
282 }
283 return payload
284 }
285
286 func parseArgsParams(args []string) map[string][]string {
287 if len(args) == 0 {
288 return nil
289 }
290
291 params := make(map[string][]string)
292 for _, arg := range args {
293 if arg == "info" {
294 continue
295 }
296 parts := strings.SplitN(arg, ":", 2)
297 if len(parts) != 2 {
298 parts = strings.SplitN(arg, "=", 2)
299 }
300 if len(parts) != 2 {
301 continue
302 }
303 key, value := parts[0], parts[1]
304 if key == "" || value == "" {
305 continue
306 }
307 params[key] = splitCSV(value)
308 }
309 return params
310 }
311
312 func paramValues(args map[string][]string, payload map[string]any, key string) []string {
313 if args != nil {
314 if values := args[key]; len(values) > 0 {
315 return values
316 }
317 }
318 return extractParamValues(payload, key)
319 }
320
321 func extractParamValues(payload map[string]any, key string) []string {
322 if payload == nil {
323 return nil
324 }
325 if selections, ok := payload["selections"].(map[string]any); ok {
326 if values := extractValues(selections[key]); len(values) > 0 {
327 return values
328 }
329 }
330 return extractValues(payload[key])
331 }
332
333 func extractValues(value any) []string {
334 switch current := value.(type) {
335 case string:
336 if current == "" {
337 return nil
338 }
339 return []string{current}
340 case []any:
341 var out []string
342 for _, item := range current {
343 if s, ok := item.(string); ok && s != "" {
344 out = append(out, s)
345 }
346 }
347 return out
348 case []string:
349 var out []string
350 for _, item := range current {
351 if item != "" {
352 out = append(out, item)
353 }
354 }
355 return out
356 default:
357 return nil
358 }
359 }
360
361 func splitCSV(value string) []string {
362 if !strings.Contains(value, ",") {
363 return []string{value}
364 }
365
366 parts := strings.Split(value, ",")
367 out := make([]string, 0, len(parts))
368 for _, part := range parts {
369 if part == "" {
370 continue
371 }
372 out = append(out, part)
373 }
374 return out
375 }
376
377 func buildJobParamConfig(jobs []string) funcapi.ParamConfig {
378 options := make([]funcapi.ParamOption, 0, len(jobs))
379 if len(jobs) == 0 {
380 options = append(options, funcapi.ParamOption{
381 ID: "",
382 Name: "(No instances configured)",
383 Disabled: true,
384 })
385 } else {
386 for i, job := range jobs {
387 option := funcapi.ParamOption{
388 ID: job,
389 Name: job,
390 }
391 if i == 0 {
392 option.Default = true
393 }
394 options = append(options, option)
395 }
396 }
397
398 return funcapi.ParamConfig{
399 ID: paramJob,
400 Name: "Instance",
401 Help: "Select which database instance to query",
402 Selection: funcapi.ParamSelect,
403 Options: options,
404 UniqueView: true,
405 }
406 }
407
408 func buildAcceptedParams(methodParams []funcapi.ParamConfig, includeJobParam bool) []string {
409 accepted := make([]string, 0, len(methodParams)+1)
410 if includeJobParam {
411 accepted = append(accepted, paramJob)
412 }
413 for _, param := range methodParams {
414 if !slices.Contains(accepted, param.ID) {
415 accepted = append(accepted, param.ID)
416 }
417 }
418 return accepted
419 }
420
421 func methodRequiresJobParam(cfg *funcapi.MethodConfig) bool {
422 return cfg == nil || !cfg.AgentWide
423 }
424
425 func (c *Controller) makeJobMethodFuncHandler(moduleName, jobName, methodID string) func(functions.Function) {
426 return func(fn functions.Function) {
427 if slices.Contains(fn.Args, "info") {
428 c.handleJobMethodFuncInfo(moduleName, jobName, methodID, fn)
429 return
430 }
431
432 methodCfg, ok := c.registry.getJobMethod(moduleName, jobName, methodID)
433 if !ok {
434 c.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
435 return
436 }
437
438 payload := parsePayload(fn.Payload)
439 argValues := parseArgsParams(fn.Args)
440
441 job, jobGen := c.registry.getJobWithGeneration(moduleName, jobName)
442 if job == nil {
443 c.respondError(fn, 503, "job '%s:%s' is not running", moduleName, jobName)
444 return
445 }
446
447 c.executeMethodRequest(methodExecutionInput{
448 fn: fn,
449 moduleName: moduleName,
450 jobName: jobName,
451 jobLabel: fmt.Sprintf("%s:%s", moduleName, jobName),
452 methodID: methodID,
453 methodCfg: methodCfg,
454 job: job,
455 jobGen: jobGen,
456 payload: payload,
457 argValues: argValues,
458 resolveParams: func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
459 return c.resolveJobMethodParams(ctx, methodCfg, handler, methodID)
460 },
461 respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
462 c.respondJobMethodWithParams(fn, dataResp, methodParams, updateEvery, methodCfg.ResponseType)
463 },
464 })
465 }
466 }
467
468 func (c *Controller) handleJobMethodFuncInfo(moduleName, jobName, methodID string, fn functions.Function) {
469 methodCfg, ok := c.registry.getJobMethod(moduleName, jobName, methodID)
470 if !ok {
471 c.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
472 return
473 }
474
475 methodParams := methodCfg.RequiredParams
476 help := methodCfg.Help
477 if help == "" {
478 help = fmt.Sprintf("%s %s data function", moduleName, methodID)
479 }
480
481 updateEvery := max(methodCfg.UpdateEvery, 1)
482
483 resp := map[string]any{
484 "v": 3,
485 "update_every": updateEvery,
486 "status": 200,
487 "type": resolveResponseType("", methodCfg.ResponseType),
488 "has_history": false,
489 "help": help,
490 "accepted_params": buildJobMethodAcceptedParams(methodParams),
491 "required_params": buildJobMethodRequiredParams(methodParams),
492 }
493
494 if presentation := methodCfg.Presentation(); presentation != nil {
495 resp["presentation"] = presentation
496 }
497
498 c.respondJSON(fn, resp)
499 }
500
501 func (c *Controller) resolveJobMethodParams(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
502 methodParams := methodCfg.RequiredParams
503
504 jobParams, err := handler.MethodParams(ctx, methodID)
505 if err != nil {
506 return nil, false, err
507 }
508 if len(jobParams) == 0 {
509 return methodParams, true, nil
510 }
511
512 return funcapi.MergeParamConfigs(methodParams, jobParams), true, nil
513 }
514 func buildJobMethodAcceptedParams(methodParams []funcapi.ParamConfig) []string {
515 accepted := make([]string, 0, len(methodParams))
516 for _, param := range methodParams {
517 if !slices.Contains(accepted, param.ID) {
518 accepted = append(accepted, param.ID)
519 }
520 }
521 return accepted
522 }
523
524 func buildJobMethodRequiredParams(methodParams []funcapi.ParamConfig) []map[string]any {
525 required := make([]map[string]any, 0, len(methodParams))
526 for _, cfg := range methodParams {
527 required = append(required, cfg.RequiredParam())
528 }
529 return required
530 }