@cryptotaxi247 / netdata-1 / commits / 141f45b22

feat(go.d/sd): add dyncfg support for service discovery (#21680)

* refactor(go.d/sd): extract PipelineManager and move FunctionRegistry Refactoring in preparation for adding dyncfg support to service discovery: - Move FunctionRegistry interface from jobmgr/di.go to functions/registry.go so both jobmgr and SD can use it without duplication. - Extract PipelineManager from ServiceDiscovery to handle: - Pipeline lifecycle (start, stop, restart) - Source tracking for cleanup when pipelines are disabled - Grace period mechanism (30s) for restarts to avoid gaps in metrics - Group forwarding with source interception * feat(go.d/sd): wire FunctionRegistry to service discovery Add FnReg to SD config and basic dyncfg infrastructure wiring: - Add functions.Registry parameter to buildDiscoveryConf - Add FnReg and ConfigDefaults to sd.Config - Initialize dyncfg.Responder in ServiceDiscovery - Move fnMgr creation before buildDiscoveryConf to pass it through * feat(go.d/sd): add dyncfg template registration for discoverers Add dyncfg infrastructure for service discovery: - Create dyncfg.go with template registration for discoverer types (net_listeners, docker, k8s, snmp) - Define ID format: {executable}:sd:{discovererType}:{name} - Define supported commands for templates and jobs - Register templates on SD startup, unregister on shutdown - Add placeholder config handler (commands will be implemented next) * feat(go.d/sd): add state cache for SD dyncfg Add exposedSDConfigs cache to track SD pipeline configs exposed via dyncfg: - sdConfig struct to represent pipeline config with discoverer type, name, source info, status, and raw content - Thread-safe exposedSDConfigs cache with lookup by type:name key - Helper methods for iteration and counting by discoverer type * feat(go.d/sd): implement dyncfg command handlers Implement dyncfg command handlers for service discovery: - schema: return JSON schema for discoverer type (placeholder for now) - get: return current config for a job - add: create a new dyncfg job - update: update an existing job config - enable: enable a job (change status to running) - disable: disable a job (change status to disabled) - remove: remove a dyncfg job Add helper functions: - extractDiscovererAndName: parse dyncfg ID into type and name - isValidDiscovererType: validate discoverer type - getDyncfgCommand: extract command from function args - getFnSourceValue: extract value from function source Pipeline start/stop is stubbed with TODOs for follow-up work. * feat(go.d/sd): add JSON schemas for discoverer types Add JSON schemas for dyncfg UI configuration forms: - net_listeners: interval, timeout, tags, services - docker: address, timeout, tags, services - k8s: role, namespaces, selector, pod options, tags, services - snmp: credentials, networks, rescan_interval, timeout, services Each schema includes: - Field descriptions and validation - Service rule definitions with match expressions and config templates - Appropriate defaults and examples * feat(go.d/sd): expose file configs through dyncfg Add functionality to expose file-based pipeline configs via dyncfg: - exposeFileConfig: creates dyncfg job when file config is loaded - removeExposedFileConfig: removes dyncfg job when file is removed - Extracts discoverer type from config to create correct dyncfg job ID - File configs are exposed with sourceType="file" and status=running - Fix test to initialize exposedConfigs cache * refactor(go.d/sd): extract config schemas to JSON files Move JSON schemas from Go string constants to separate files: - config_schema_net_listeners.json - config_schema_docker.json - config_schema_k8s.json - config_schema_snmp.json Use go:embed to load schemas at compile time, matching the pattern used by collectors for their config_schema.json files. * feat(go.d/sd): improve dyncfg config handling and add userconfig - Fix JSON schemas to use proper jsonSchema + uiSchema format for react-jsonschema-form compatibility - Add typed dyncfg config structs (DyncfgNetListenersConfig, DyncfgDockerConfig, DyncfgK8sConfig, DyncfgSNMPConfig) - Transform file configs to dyncfg format on load for consistent UI presentation - Add userconfig command for templates and jobs to return YAML representation - Remove convertToJSONCompatible hack in favor of proper structs * fix(go.d/sd): address review comments for dyncfg and pipeline manager - Fix potential deadlock in cleanup: use timeout context instead of context.Background() in cleanupSourcesLocked to prevent blocking forever if consumer stopped reading - Return 501 Not Implemented for unimplemented dyncfg commands: update, enable, disable, remove now return 501 instead of misleading 200 OK since pipeline control is not yet wired up - Fix lock contention in grace period cleanup: collect removals while holding lock, then send outside lock to avoid blocking other operations - Fix lock contention during pipeline shutdown: refactor to wait for pipeline stop outside the lock, preventing freeze of entire PipelineManager during shutdown (up to 10 seconds) * fix(go.d/sd): fix pipeline manager types and schema issues - Add missing waitForPipeline method for restart grace period - Fix Stop/StopAll to use waitAndCleanup with correct types - Merge pending removals on consecutive restarts to avoid leaks - Fix SNMP schema: aes192C/aes256C -> aes192c/aes256c - Fix generic schema to use jsonSchema/uiSchema wrapper format - Remove stale TODO comment from dyncfg.go * fix(go.d/sd): fix data races and implement dyncfg command serialization - Fix data races in dyncfg command handlers by using thread-safe getter methods instead of lookup() followed by field access - Add thread-safe getter methods to exposedSDConfigs: getContent(), getPipelineKey(), getSource(), getSourceType(), getStatus() - Fix Source field for file-based configs on enable (use "file=" prefix) - Implement dyncfg command serialization following jobmgr pattern: - Read-only commands (schema, get, userconfig) execute directly - State-changing commands queue via dyncfgCh channel - Commands processed serially in run() goroutine - Add pipelineKey field to sdConfig for correct pipeline identification - Implement enable, disable, update, remove commands - Add duplicate check in add command - Remove enable/disable from template commands (not needed) - Add dyncfg_parse.go for parsing dyncfg JSON payloads * refactor(go.d/dyncfg): extract shared utility functions Move GetCommand() and GetSourceValue() to the dyncfg package to eliminate code duplication between sd and jobmgr dyncfg handlers. - Add dyncfg.GetCommand() to extract command from function args - Add dyncfg.GetSourceValue() to extract values from function source - Update sd/dyncfg.go to use shared functions - Update jobmgr/dyncfg_collector.go to use shared functions - Remove duplicate local function definitions * refactor(go.d/dyncfg): add Function wrapper type with accessor methods Add dyncfg.Function wrapper that encapsulates functions.Function and provides dyncfg-specific accessor methods for cleaner API: - Fn() returns underlying functions.Function for responder calls - UID(), Source(), Payload(), ContentType() for direct field access - ID(), Command(), JobName(), User() for dyncfg-specific arg extraction - ValidateArgs(), ValidateHasPayload() for validation - UnmarshalPayload(), UnmarshalJSON(), UnmarshalYAML() for payload handling Update sd/dyncfg.go to use the new wrapper type throughout all command handlers, replacing direct field access with method calls. * fix(go.d/sd): address review feedback for dyncfg handlers Fix issues identified in code review: 1. Update command now preserves original source type (file vs dyncfg) instead of always overwriting to dyncfg source 2. Transform failure no longer caches raw YAML content that would be incorrectly served as JSON - config is not exposed if transform fails 3. Enable command now refreshes status to 'running' even when pipeline is already running, fixing stale status display in UI 4. Duration parsing now returns 400 errors with details on invalid values instead of silently falling back to defaults * refactor(go.d/jobmgr): use dyncfg.Function wrapper type consistently Update jobmgr package to use the dyncfg.Function wrapper type instead of directly accessing functions.Function fields. Changes: - Update dyncfg_vnode.go to use accessor methods (ID(), JobName(), Source(), Command(), HasPayload(), UnmarshalPayload(), ValidateArgs()) - Update dyncfg_collector.go to use fn.UnmarshalPayload() - Add handler wrapper in dyncfg.go to convert at entry point - Update manager.go to wrap functions.Function with dyncfg.NewFunction() - Update tests to use dyncfg.NewFunction() wrapper This aligns the jobmgr package with the dyncfg.Function pattern established in the sd package refactoring. * refactor(go.d/dyncfg): update Responder to accept Function wrapper Update dyncfg.Responder methods to accept dyncfg.Function instead of functions.Function for consistency within the package. Changes: - Update Responder methods (SendCodef, SendJSON, SendJSONWithCode, SendYAML) to accept Function wrapper type - Remove redundant GetCommand() and GetSourceValue() helper functions (now available as methods on Function) - Simplify callers by passing fn directly instead of fn.Fn() - Add dyncfg.NewFunction() wrapper at funcshandler.go boundary This makes the dyncfg package internally consistent - both the Function wrapper and Responder now work with the same type. * test(go.d/sd): add PipelineManager and dyncfg integration tests Add comprehensive tests for ServiceDiscovery: PipelineManager tests (pipeline_manager_test.go): - Start: new pipeline, replace existing, invalid config - Stop: removal groups for tracked sources, non-existent no-op - Restart: grace period for overlapping sources, invalid config - StopAll: stops all pipelines, sends removal groups - RunGracePeriodCleanup: expired/non-expired pending removals - IsRunning/Keys: state verification - ConcurrentOperations: thread safety Dyncfg integration tests (dyncfg_test.go): - Schema: template and unknown discoverer type - Add: new job, missing payload, duplicate detection - Get: existing and non-existent jobs - Enable/Disable: pipeline lifecycle management - Update: existing and non-existent jobs - Remove: dyncfg jobs, running jobs, non-existent jobs - Userconfig: template and job configurations - FileConfig: prevent removal of file-based configs - MultipleJobs: complete lifecycle with multiple jobs * feat(go.d/sd): improve dyncfg schema UI for service discovery - Add tabs layout (Base + Services) for all discoverer types - Add textarea widget for match field in service rules - Add list flavour for services, credentials, and networks arrays - Remove unused 'tags' config option (legacy from old classify/compose format) - Add markdown documentation for match field with available target fields - SNMP schema has 4 tabs: Base, Credentials, Networks, Services * test(go.d/sd): add tests for docker/k8s/snmp configs and update-while-running Add comprehensive dyncfg tests for: - Docker config parsing (add, enable, get) - Kubernetes config parsing (pod role, service role, selectors) - SNMP config parsing (v2c, v3 with auth/priv) - Update command while pipeline is running (verifies restart) Also implement the "test" dyncfg command that validates config payloads without creating jobs or starting pipelines. * add snmp version 2c * style(go.d/sd): fix gofmt alignment in pipeline_manager.go * refactor(go.d/sd): use non-pointer Duration fields and fix JSON unmarshal - Fix confopt.Duration.UnmarshalJSON to handle both JSON numbers (30) and quoted strings ("30s", "5m") - Change all Duration fields from pointer to non-pointer throughout: - Dyncfg configs (Interval, Timeout, RescanInterval, DeviceCacheTTL) - Discoverer configs (netlistensd, snmpsd) - Update discoverer code to use zero-means-default pattern: - Zero = use default value - Positive = use specified value - Negative (snmpsd only) = special meaning (disable rescan/cache never expires) - Increase grace period from 30 seconds to 1 minute - Update tests for new Duration semantics * refactor(go.d/sd): remove legacy Tags field from discoverer configs The Tags field was only used by the legacy Classify/Compose system. The new Services format uses match expressions directly against target fields, making discoverer-level tags unnecessary. - Remove Tags field from netlistensd, dockersd, k8ssd configs - Remove tags parsing and propagation code from discoverers - Remove tags: "unknown" from file-based SD configs - Update tests to not expect tags on discovered targets * fix(go.d/sd): fix race conditions in dyncfg update and pipeline shutdown - Fix update storing config before validating restart success - Fix unbuffered dyncfgCh causing potential shutdown hang - Fix race where removed pipeline could orphan source tracking entries * fix(go.d/dyncfg): rename UnmarshalJSON to avoid interface conflict go vet reports error when method named UnmarshalJSON doesn't match the json.Unmarshaler interface signature. Renamed to private methods since they're only used internally. * fix(go.d/sd): validate config on add and prevent file config name collisions - Validate dyncfg payload in add command before storing - Prevent name collisions for file configs by using source path when another config with same discovererType:name already exists * fix(go.d/sd): address review findings for race conditions and validation - Fix race in Restart: cancel existing pipeline before starting new one in startPipelineWithInstanceLocked to prevent orphaned goroutines - Fix panic risk: use > 0 instead of != 0 for duration checks in netlistensd and dockersd to reject negative values - Fix stale dyncfg jobs: remove existing jobs before exposing updated file config to handle name/type changes - Improve concurrent test: track created vs stopped pipelines to detect goroutine leaks * refactor(go.d/sd): extract shared functions and fix naming consistency - Extract duplicated utilities to model/funcs.go: - CalcHash: centralized hash calculation using hashstructure - MapAny: convert string maps to any maps - SendTargetGroup: context-aware target group sending - Update all discoverers to use shared functions: - netlistensd: use model.CalcHash - dockersd: use model.CalcHash and model.MapAny - k8ssd: use model.CalcHash, model.MapAny, model.SendTargetGroup - snmpsd: use model.CalcHash - Fix naming inconsistency: - Rename k8ssd.NewKubeDiscoverer to k8ssd.NewDiscoverer - All discoverers now consistently use NewDiscoverer - Fix pipeline manager race: - Preserve interloper pipeline sources in pendingRemovals - Prevents orphaned jobs when race occurs during Restart * refactor(go.d/sd): remove unnecessary race check in pipeline manager Remove defensive check for concurrent Start/Restart on same pipeline key. This race cannot occur because ServiceDiscovery.run() processes all events (file config changes and dyncfg commands) sequentially in a single select loop. Update test to verify valid concurrent operations (different keys) instead of unsupported same-key concurrency. * style(go.d/sd): go fmt discoverers * feat(go.d/sd): implement wait-for-enable pattern for file configs - Add wait-for-enable flow matching jobmgr behavior: expose file configs with Accepted status and wait for netdata's enable/disable command - Add terminal detection using isatty to auto-enable in terminal mode - Add name sanitization (spaces/colons to underscores) via: - pipeline.Config.CleanName() for SD configs - dyncfg.Function.JobName() for dyncfg add commands - Fix dyncfg ID parsing issues caused by spaces/colons in names * refactor(go.d/sd): unify dyncfg and file config formats Restructure dyncfg config types to include discoverer wrapper, matching JSON schema and file-based config formats. This allows users to copy YAML from dyncfg userconfig action and paste it directly into configuration files. Changes: - Add discoverer wrapper to dyncfg config types - Update transform functions to produce new format - Update parse functions to read new format - Add automatic legacy format conversion during unmarshal - Update JSON schemas to match unified format - Update tests with helper functions for new format * refactor(go.d/sd): remove dyncfg config duplication Use pipeline.Config directly for dyncfg serialization instead of maintaining separate duplicate types. Changes: - Add JSON tags to discoverer configs (netlistensd, dockersd, k8ssd, snmpsd) - Add JSON tags to pipeline.Config (exclude internal fields) - Remove dyncfg_config.go (duplicate types no longer needed) - Remove dyncfg_transform.go (use json.Marshal directly) - Simplify dyncfg_parse.go (unmarshal into pipeline.Config directly) - Update tests to use actual types from their packages * fix(go.d/sd): add JSON tags to ServiceRuleConfig * refactor(go.d/sd): implement dual cache and priority handling - Add dual cache system (seenConfigs + exposedConfigs) matching jobmgr pattern - Implement config priority: dyncfg (16) > user file (8) > stock file (2) - Add name sanitization for dyncfg IDs (replace spaces/colons with underscores) - Use Restart when pipeline already running (validates first, preserves old if new fails) - Change sdConfig to map[string]any with __ metadata fields - Add newSDConfigFromYAML and newSDConfigFromJSON with name cleaning - Update tests to use proper discoverer configs instead of special handling * fix(go.d/sd): fix data race and add priority tests - Fix data race: lookup methods now return clones instead of references - Fix Clone() to use JSON marshal/unmarshal for proper deep copy - Fix Status() to handle both dyncfg.Status and string after JSON clone - Fix sourceTypeFromPath to correctly identify stock vs user paths - Add priority tests for stock/user config combinations - Add dyncfg priority tests for file vs dyncfg scenarios * refactor(go.d/sd): restore all metadata in Clone() Move type restoration from Status() getter to Clone() method. Restore all metadata keys after JSON unmarshal for consistency. * fix(go.d/sd): add full validation to dyncfg add/test commands - Export ValidateConfig in pipeline package for dyncfg validation - Add full semantic validation in parseDyncfgPayload (name, discoverer, services rules) - Reject empty discoverer upfront instead of at enable time - Add minItems:1 to credentials/networks in SNMP schema - Add services to required fields in all SD schemas - Update tests to use valid configs with service rules * fix(go.d/sd): review fixes for dyncfg and pipeline - dyncfg update: convert file config to dyncfg (creates persistent override) - dyncfg update: clean old dyncfg from seenConfigs, keep file for re-exposure - dyncfg_cache: add nil map check after JSON unmarshal - pipeline: remove dead legacy code in New() (services always required) * remove legacy code from sd pipeline * fix(go.d/sd): fix name mismatch and error handling in dyncfg update - Force name from dyncfg job ID onto sdConfig (matching jobmgr pattern) This ensures sdConfig.Key() matches the dyncfg job ID regardless of payload content, fixing lookups for enable/update/remove commands. - Fix dyncfgCmdUpdate error handling: - On conversion start failure: set status to Failed, update dyncfg UI, return 200 (user can retry via enable) - On restart failure: set status to Failed, update dyncfg UI, return 200 (was returning 422 without status update) - Remove defer for dyncfgSDJobCreate (captured status at wrong time) - Add TODO for promotion of lower-priority config after removal * some fixes * refactor(go.d/sd): align userconfig command with jobmgr pattern - Always require payload for userconfig (both templates and jobs) - Unmarshal JSON into pipeline.Config, then marshal to YAML - This ensures consistent field ordering and validates structure - Add userConfigFromPayload helper in dyncfg_parse.go - Update test to send payload for jobs * refactor(sd/dyncfg): align get command with jobmgr pattern - Add configToJSON helper that unmarshals JSON into pipeline.Config then marshals back to JSON for consistent field ordering - Update dyncfgCmdGet to use configToJSON instead of raw DataJSON() - This matches jobmgr's pattern of using typed structs for marshaling * refactor(sd/dyncfg): align add command with jobmgr pattern Changes to dyncfgCmdAdd: - Add job name validation (reject spaces, '.', ':' characters) - Move Info log after all validation, before cache operations - Replace duplicate handling with jobmgr pattern: - Always allow replacing existing config - Only remove from seenConfigs if existing is dyncfg - File configs stay in seenConfigs for potential re-exposure - Send 202 response before creating dyncfg job (matching jobmgr order) Add validateJobName helper to dyncfg_parse.go. Update tests to reflect new behavior: - Duplicate add now replaces instead of failing - Response sent before job create - No CONFIG delete when replacing (CONFIG create updates existing) * refactor(sd/dyncfg): use sdConfig for cache lookups instead of strings - Change cache lookup/remove methods to accept sdConfig, derive key internally - Add newLookupConfig helper for creating minimal lookup configs - Remove lookupByName method in favor of consistent lookup(cfg) pattern - Simplify add command: remove redundant IsRunning check before Stop * refactor(sd/dyncfg): remove redundant IsRunning check in remove command * refactor(sd/dyncfg): align enable command with jobmgr pattern - Add status check: Running returns early (idempotent), Accepted/Disabled/Failed proceed, others reject with 405 - Only log "enable by user" when status was Disabled - Use simple Start() without graceful degradation - config should reflect current file state - Response before status update (matches jobmgr order) * refactor(sd/dyncfg): align disable command with jobmgr pattern - Use status check instead of IsRunning: Disabled returns early (idempotent), Running stops pipeline, others proceed - Log "disable by user" once at the end - Response before status update (matches jobmgr order) * refactor(sd/dyncfg): align update command with jobmgr pattern - Use hash-based comparison instead of raw JSON string comparison for detecting unchanged configs (JSON field order is not guaranteed) - Add Hash() and HashIncludeMap() methods to sdConfig using hashstructure - Fix response order: send response before status update - Skip unchanged config optimization only for dyncfg-to-dyncfg updates (file-to-dyncfg conversions must always proceed) * refactor(sd/dyncfg): align update/remove with jobmgr patterns - Block update in Accepted state (return 403), matching jobmgr behavior - Fix remove response order: response before delete (matching jobmgr) - Add test for update in accepted state fails scenario - Update test expectations for new response order * refactor(sd/dyncfg): use Restart for non-conversion updates Use mgr.Restart() instead of Stop()+Start() for non-conversion dyncfg updates. Restart validates new config before stopping old pipeline and uses grace period for seamless transition. * test(go.d/sd): add missing dyncfg test cases and remove unused methods - Remove unused forEach/forEachBySource methods from exposedSDConfigs - Add test: update running pipeline with same config (hash optimization) - Add test: update config in Failed state - Add test: enable config from Failed state - Add test: file->dyncfg conversion via update command - Add test: restart error handling (old pipeline keeps running) - Add test: file removal with dyncfg override * refactor(go.d/sd): fix schema durations and add test command for jobs - Fix JSON schema files to use numeric duration values (seconds) instead of string patterns, matching confopt.Duration's JSON marshaling - Add test command to job dyncfg commands for config validation - Update dyncfgCmdTest to handle both templates and jobs with appropriate logging * feat(confopt): add LongDuration type for human-friendly duration strings Add LongDuration type that marshals to human-friendly strings ("12h", "1d", "30m") instead of raw seconds. Supports all duration units: y, mo, w, d, h, m, s, ms. Update SD discoverer configs to use LongDuration for interval/TTL fields: - netlistensd.Config.Interval - snmpsd.Config.RescanInterval - snmpsd.Config.DeviceCacheTTL Keep Duration (seconds) for timeout fields where numeric values are more appropriate. Update JSON schemas to use string type with pattern for LongDuration fields. * fix(go.d): address review comments for dyncfg - Validate that only one discoverer type is configured, reject configs with multiple discoverers instead of silently ignoring some - Use fn.JobName() in vnodeUserconfigFromPayload for consistent name sanitization (replaces spaces/colons with underscores) - Use json.Unmarshal instead of yaml.Unmarshal in Config.UnmarshalJSON to properly implement json.Unmarshaler contract * remove legacy classificator and composer * refactor(go.d/sd): remove unnecessary UnmarshalJSON wrapper * feat(go.d/sd): temporarily disable dyncfg integration for release Add disableDyncfg flag (default true) to bypass SD dyncfg integration: - Skip template registration when flag is true - Start pipelines directly without dyncfg job creation - Tests set flag to false to preserve existing behavior This allows the release to proceed while SD dyncfg feature is further validated. To re-enable: set disableDyncfg = false.

Ilya Mashchenko committed Feb 5, 2026 at 19:37 UTC 141f45b229628fe65bcc0c6fba7bbd8635199c03
57 files changed +6839 -1254
src/go/pkg/confopt/duration.go
+120 -12
@@ -108,25 +108,133 @@ func (d Duration) MarshalYAML() (any, error) {
108 }
109
110 func (d *Duration) UnmarshalJSON(b []byte) error {
111 - s := string(b)
112 -
113 - if v, err := time.ParseDuration(s); err == nil {
114 - *d = Duration(v)
115 - return nil
116 - }
117 - if v, err := strconv.ParseInt(s, 10, 64); err == nil {
118 - *d = Duration(time.Duration(v) * time.Second)
119 - return nil
111 + // Try as JSON string first (handles quoted values like "30m", "5s")
112 + var s string
113 + if err := json.Unmarshal(b, &s); err == nil {
114 + if v, err := ParseDuration(s); err == nil {
115 + *d = Duration(v)
116 + return nil
117 + }
118 + // Try as numeric string (interpret as seconds)
119 + if v, err := strconv.ParseFloat(s, 64); err == nil {
120 + *d = Duration(v * float64(time.Second))
121 + return nil
122 + }
123 }
121 - if v, err := strconv.ParseFloat(s, 64); err == nil {
122 - *d = Duration(v * float64(time.Second))
124 +
125 + // Try as JSON number (handles unquoted values like 5, 1.5)
126 + var f float64
127 + if err := json.Unmarshal(b, &f); err == nil {
128 + *d = Duration(f * float64(time.Second))
129 return nil
130 }
131
126 - return fmt.Errorf("unparsable duration format '%s'", s)
132 + return fmt.Errorf("unparsable duration format '%s'", string(b))
133 }
134
135 func (d Duration) MarshalJSON() ([]byte, error) {
136 seconds := float64(d) / float64(time.Second)
137 return json.Marshal(seconds)
138 }
139 +
140 +// LongDuration is like Duration but marshals to a human-friendly string (e.g., "12h", "30m", "1d").
141 +// Unmarshal accepts both strings ("12h", "1d") and numbers (seconds).
142 +type LongDuration time.Duration
143 +
144 +func (d LongDuration) Duration() time.Duration {
145 + return time.Duration(d)
146 +}
147 +
148 +func (d LongDuration) String() string {
149 + return formatDuration(time.Duration(d))
150 +}
151 +
152 +func (d *LongDuration) UnmarshalYAML(unmarshal func(any) error) error {
153 + var tmp Duration
154 + if err := tmp.UnmarshalYAML(unmarshal); err != nil {
155 + return err
156 + }
157 + *d = LongDuration(tmp)
158 + return nil
159 +}
160 +
161 +func (d LongDuration) MarshalYAML() (any, error) {
162 + return formatDuration(time.Duration(d)), nil
163 +}
164 +
165 +func (d *LongDuration) UnmarshalJSON(b []byte) error {
166 + var tmp Duration
167 + if err := tmp.UnmarshalJSON(b); err != nil {
168 + return err
169 + }
170 + *d = LongDuration(tmp)
171 + return nil
172 +}
173 +
174 +func (d LongDuration) MarshalJSON() ([]byte, error) {
175 + return json.Marshal(formatDuration(time.Duration(d)))
176 +}
177 +
178 +// formatDuration formats a duration as a human-friendly string.
179 +// Uses the largest unit that produces a clean integer value, with preference
180 +// for fractional seconds over milliseconds when value >= 1s.
181 +// Supported units: y (365d), mo (30d), w (7d), d (24h), h, m, s, ms.
182 +func formatDuration(d time.Duration) string {
183 + if d == 0 {
184 + return "0s"
185 + }
186 +
187 + neg := d < 0
188 + if neg {
189 + d = -d
190 + }
191 +
192 + // Units from largest to smallest (excluding ms - handled separately)
193 + units := []struct {
194 + suffix string
195 + value time.Duration
196 + }{
197 + {"y", 365 * 24 * time.Hour},
198 + {"mo", 30 * 24 * time.Hour},
199 + {"w", 7 * 24 * time.Hour},
200 + {"d", 24 * time.Hour},
201 + {"h", time.Hour},
202 + {"m", time.Minute},
203 + {"s", time.Second},
204 + }
205 +
206 + // Find the largest unit that divides evenly
207 + for _, u := range units {
208 + if d >= u.value && d%u.value == 0 {
209 + val := d / u.value
210 + if neg {
211 + return fmt.Sprintf("-%d%s", val, u.suffix)
212 + }
213 + return fmt.Sprintf("%d%s", val, u.suffix)
214 + }
215 + }
216 +
217 + // For values >= 1s without clean division, prefer fractional seconds over ms
218 + if d >= time.Second {
219 + seconds := float64(d) / float64(time.Second)
220 + if neg {
221 + return fmt.Sprintf("-%.3gs", seconds)
222 + }
223 + return fmt.Sprintf("%.3gs", seconds)
224 + }
225 +
226 + // For sub-second values, try milliseconds
227 + if d >= time.Millisecond && d%time.Millisecond == 0 {
228 + val := d / time.Millisecond
229 + if neg {
230 + return fmt.Sprintf("-%dms", val)
231 + }
232 + return fmt.Sprintf("%dms", val)
233 + }
234 +
235 + // Fallback to Go's standard format for sub-millisecond precision
236 + if neg {
237 + return "-" + d.String()
238 + }
239 + return d.String()
240 +}
src/go/pkg/confopt/duration_test.go
+257 -18
@@ -156,34 +156,273 @@ func TestDuration_UnmarshalYAML(t *testing.T) {
156
157 func TestDuration_UnmarshalJSON(t *testing.T) {
158 tests := map[string]struct {
159 - input any
159 + input string
160 + expected time.Duration
161 + wantErr bool
162 }{
161 - "duration": {input: "300ms"},
162 - "string int": {input: "1"},
163 - "string float": {input: "1.1"},
164 - "int": {input: 2},
165 - "float": {input: 2.2},
163 + // JSON numbers (interpreted as seconds)
164 + "json number int": {
165 + input: `{"d": 30}`,
166 + expected: 30 * time.Second,
167 + },
168 + "json number float": {
169 + input: `{"d": 1.5}`,
170 + expected: 1500 * time.Millisecond,
171 + },
172 + "json number zero": {
173 + input: `{"d": 0}`,
174 + expected: 0,
175 + },
176 +
177 + // JSON strings with duration format
178 + "json string seconds": {
179 + input: `{"d": "30s"}`,
180 + expected: 30 * time.Second,
181 + },
182 + "json string minutes": {
183 + input: `{"d": "5m"}`,
184 + expected: 5 * time.Minute,
185 + },
186 + "json string hours": {
187 + input: `{"d": "2h"}`,
188 + expected: 2 * time.Hour,
189 + },
190 + "json string milliseconds": {
191 + input: `{"d": "500ms"}`,
192 + expected: 500 * time.Millisecond,
193 + },
194 + "json string combined": {
195 + input: `{"d": "1h30m"}`,
196 + expected: 90 * time.Minute,
197 + },
198 + "json string days": {
199 + input: `{"d": "1d"}`,
200 + expected: 24 * time.Hour,
201 + },
202 + "json string weeks": {
203 + input: `{"d": "1w"}`,
204 + expected: 7 * 24 * time.Hour,
205 + },
206 +
207 + // JSON strings with numeric values (interpreted as seconds)
208 + "json string numeric int": {
209 + input: `{"d": "30"}`,
210 + expected: 30 * time.Second,
211 + },
212 + "json string numeric float": {
213 + input: `{"d": "1.5"}`,
214 + expected: 1500 * time.Millisecond,
215 + },
216 +
217 + // JSON null (results in zero value, not an error)
218 + "json null": {
219 + input: `{"d": null}`,
220 + expected: 0,
221 + },
222 +
223 + // Errors
224 + "json string invalid": {
225 + input: `{"d": "invalid"}`,
226 + wantErr: true,
227 + },
228 }
229
168 - var zero Duration
230 + for name, tc := range tests {
231 + t.Run(name, func(t *testing.T) {
232 + var result struct {
233 + D Duration `json:"d"`
234 + }
235 +
236 + err := json.Unmarshal([]byte(tc.input), &result)
237
170 - type duration struct {
171 - D Duration `json:"d"`
238 + if tc.wantErr {
239 + assert.Error(t, err)
240 + return
241 + }
242 +
243 + require.NoError(t, err)
244 + assert.Equal(t, tc.expected, result.D.Duration())
245 + })
246 }
173 - type input struct {
174 - D any `json:"d"`
247 +}
248 +
249 +func TestFormatDuration(t *testing.T) {
250 + tests := map[string]struct {
251 + d time.Duration
252 + want string
253 + }{
254 + "zero": {d: 0, want: "0s"},
255 + "1 millisecond": {d: time.Millisecond, want: "1ms"},
256 + "500 milliseconds": {d: 500 * time.Millisecond, want: "500ms"},
257 + "1 second": {d: time.Second, want: "1s"},
258 + "30 seconds": {d: 30 * time.Second, want: "30s"},
259 + "1 minute": {d: time.Minute, want: "1m"},
260 + "5 minutes": {d: 5 * time.Minute, want: "5m"},
261 + "30 minutes": {d: 30 * time.Minute, want: "30m"},
262 + "1 hour": {d: time.Hour, want: "1h"},
263 + "12 hours": {d: 12 * time.Hour, want: "12h"},
264 + "1 day": {d: 24 * time.Hour, want: "1d"},
265 + "7 days": {d: 7 * 24 * time.Hour, want: "1w"},
266 + "14 days": {d: 14 * 24 * time.Hour, want: "2w"},
267 + "30 days": {d: 30 * 24 * time.Hour, want: "1mo"},
268 + "365 days": {d: 365 * 24 * time.Hour, want: "1y"},
269 + "2 years": {d: 2 * 365 * 24 * time.Hour, want: "2y"},
270 + "negative 1 hour": {d: -time.Hour, want: "-1h"},
271 + "negative 1 day": {d: -24 * time.Hour, want: "-1d"},
272 + "1.5 seconds": {d: 1500 * time.Millisecond, want: "1.5s"},
273 + "90 minutes": {d: 90 * time.Minute, want: "90m"},
274 + "36 hours": {d: 36 * time.Hour, want: "36h"},
275 + "sub-millisecond": {d: 100 * time.Microsecond, want: "100µs"},
276 + "nanoseconds": {d: 50 * time.Nanosecond, want: "50ns"},
277 + "negative sub-ms": {d: -100 * time.Microsecond, want: "-100µs"},
278 }
279
177 - for name, test := range tests {
178 - name = fmt.Sprintf("%s (%v)", name, test.input)
280 + for name, tc := range tests {
281 + t.Run(name, func(t *testing.T) {
282 + got := formatDuration(tc.d)
283 + assert.Equal(t, tc.want, got)
284 + })
285 + }
286 +}
287 +
288 +func TestDurationString_MarshalJSON(t *testing.T) {
289 + tests := map[string]struct {
290 + d LongDuration
291 + want string
292 + }{
293 + "1 second": {d: LongDuration(time.Second), want: `"1s"`},
294 + "30 seconds": {d: LongDuration(30 * time.Second), want: `"30s"`},
295 + "2 minutes": {d: LongDuration(2 * time.Minute), want: `"2m"`},
296 + "12 hours": {d: LongDuration(12 * time.Hour), want: `"12h"`},
297 + "1 day": {d: LongDuration(24 * time.Hour), want: `"1d"`},
298 + "1 week": {d: LongDuration(7 * 24 * time.Hour), want: `"1w"`},
299 + "1 month": {d: LongDuration(30 * 24 * time.Hour), want: `"1mo"`},
300 + "1 year": {d: LongDuration(365 * 24 * time.Hour), want: `"1y"`},
301 + "1.5 seconds": {d: LongDuration(1500 * time.Millisecond), want: `"1.5s"`},
302 + }
303 +
304 + for name, tc := range tests {
305 + t.Run(name, func(t *testing.T) {
306 + bs, err := json.Marshal(&tc.d)
307 + require.NoError(t, err)
308 + assert.Equal(t, tc.want, string(bs))
309 + })
310 + }
311 +}
312 +
313 +func TestDurationString_MarshalYAML(t *testing.T) {
314 + tests := map[string]struct {
315 + d LongDuration
316 + want string
317 + }{
318 + "1 second": {d: LongDuration(time.Second), want: "1s"},
319 + "12 hours": {d: LongDuration(12 * time.Hour), want: "12h"},
320 + "1 day": {d: LongDuration(24 * time.Hour), want: "1d"},
321 + }
322 +
323 + for name, tc := range tests {
324 t.Run(name, func(t *testing.T) {
180 - input := input{D: test.input}
181 - data, err := yaml.Marshal(input)
325 + bs, err := yaml.Marshal(&tc.d)
326 require.NoError(t, err)
327 + assert.Equal(t, tc.want, strings.TrimSpace(string(bs)))
328 + })
329 + }
330 +}
331
184 - var d duration
185 - require.NoError(t, yaml.Unmarshal(data, &d))
186 - assert.NotEqual(t, zero.String(), d.D.String())
332 +func TestDurationString_UnmarshalJSON(t *testing.T) {
333 + tests := map[string]struct {
334 + input string
335 + expected time.Duration
336 + wantErr bool
337 + }{
338 + // JSON numbers (interpreted as seconds)
339 + "json number int": {
340 + input: `{"d": 30}`,
341 + expected: 30 * time.Second,
342 + },
343 + "json number float": {
344 + input: `{"d": 1.5}`,
345 + expected: 1500 * time.Millisecond,
346 + },
347 +
348 + // JSON strings with duration format
349 + "json string seconds": {
350 + input: `{"d": "30s"}`,
351 + expected: 30 * time.Second,
352 + },
353 + "json string hours": {
354 + input: `{"d": "12h"}`,
355 + expected: 12 * time.Hour,
356 + },
357 + "json string days": {
358 + input: `{"d": "1d"}`,
359 + expected: 24 * time.Hour,
360 + },
361 + "json string weeks": {
362 + input: `{"d": "1w"}`,
363 + expected: 7 * 24 * time.Hour,
364 + },
365 + "json string months": {
366 + input: `{"d": "1mo"}`,
367 + expected: 30 * 24 * time.Hour,
368 + },
369 + "json string years": {
370 + input: `{"d": "1y"}`,
371 + expected: 365 * 24 * time.Hour,
372 + },
373 +
374 + // JSON strings with numeric values (interpreted as seconds)
375 + "json string numeric": {
376 + input: `{"d": "120"}`,
377 + expected: 120 * time.Second,
378 + },
379 +
380 + // Errors
381 + "json string invalid": {
382 + input: `{"d": "invalid"}`,
383 + wantErr: true,
384 + },
385 + }
386 +
387 + for name, tc := range tests {
388 + t.Run(name, func(t *testing.T) {
389 + var result struct {
390 + D LongDuration `json:"d"`
391 + }
392 +
393 + err := json.Unmarshal([]byte(tc.input), &result)
394 +
395 + if tc.wantErr {
396 + assert.Error(t, err)
397 + return
398 + }
399 +
400 + require.NoError(t, err)
401 + assert.Equal(t, tc.expected, result.D.Duration())
402 + })
403 + }
404 +}
405 +
406 +func TestDurationString_UnmarshalYAML(t *testing.T) {
407 + tests := map[string]struct {
408 + input string
409 + expected time.Duration
410 + }{
411 + "duration string": {input: "d: 12h", expected: 12 * time.Hour},
412 + "duration days": {input: "d: 1d", expected: 24 * time.Hour},
413 + "numeric int": {input: "d: 120", expected: 120 * time.Second},
414 + "numeric float": {input: "d: 1.5", expected: 1500 * time.Millisecond},
415 + }
416 +
417 + for name, tc := range tests {
418 + t.Run(name, func(t *testing.T) {
419 + var result struct {
420 + D LongDuration `yaml:"d"`
421 + }
422 +
423 + err := yaml.Unmarshal([]byte(tc.input), &result)
424 + require.NoError(t, err)
425 + assert.Equal(t, tc.expected, result.D.Duration())
426 })
427 }
428 }
src/go/plugin/go.d/agent/agent.go
+3 -3
@@ -235,7 +235,9 @@ func (a *Agent) run(ctx context.Context) {
235 return
236 }
237
238 - discCfg := a.buildDiscoveryConf(enabledModules)
238 + fnMgr := functions.NewManager()
239 +
240 + discCfg := a.buildDiscoveryConf(enabledModules, fnMgr)
241
242 discMgr, err := discovery.NewManager(discCfg)
243 if err != nil {
@@ -246,8 +248,6 @@ func (a *Agent) run(ctx context.Context) {
248 return
249 }
250
249 - fnMgr := functions.NewManager()
250 -
251 jobMgr := jobmgr.New()
252 jobMgr.PluginName = a.Name
253 jobMgr.Out = a.Out
src/go/plugin/go.d/agent/discovery/sd/config_schema_docker.json new
+132
@@ -0,0 +1,132 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "Docker Service Discovery",
5 + "description": "Discovers services running in Docker containers.",
6 + "type": "object",
7 + "properties": {
8 + "name": {
9 + "title": "Name",
10 + "description": "Pipeline name (must be unique).",
11 + "type": "string",
12 + "minLength": 1
13 + },
14 + "discoverer": {
15 + "title": "Discoverer",
16 + "type": "object",
17 + "properties": {
18 + "docker": {
19 + "title": "Docker",
20 + "type": "object",
21 + "properties": {
22 + "address": {
23 + "title": "Docker address",
24 + "description": "Docker daemon address.",
25 + "type": "string",
26 + "default": "unix:///var/run/docker.sock"
27 + },
28 + "timeout": {
29 + "title": "Timeout",
30 + "description": "Timeout for Docker API calls, in seconds.",
31 + "type": "number",
32 + "minimum": 0.1,
33 + "default": 2
34 + }
35 + }
36 + }
37 + },
38 + "required": [
39 + "docker"
40 + ]
41 + },
42 + "services": {
43 + "title": "Service rules",
44 + "description": "- Match discovered Docker containers and generate collector configurations.\n- Each rule specifies match criteria and a config template.\n- When a container matches, a data collection job is created.",
45 + "type": "array",
46 + "minItems": 1,
47 + "items": {
48 + "type": "object",
49 + "properties": {
50 + "id": {
51 + "title": "Rule ID",
52 + "description": "Unique identifier for this rule. Used in logs to identify which rule matched a container.",
53 + "type": "string"
54 + },
55 + "match": {
56 + "title": "Match expression",
57 + "description": "Go template expression that must evaluate to 'true' for the rule to match the discovered container.",
58 + "type": "string"
59 + },
60 + "config_template": {
61 + "title": "Config template",
62 + "description": "**Uses the same fields as match expression**. Go template that generates the data collection job configuration in YAML format. **Must include 'module' and 'name' fields**, plus any module-specific settings.",
63 + "type": "string"
64 + }
65 + },
66 + "required": [
67 + "id",
68 + "match"
69 + ]
70 + }
71 + }
72 + },
73 + "required": [
74 + "name",
75 + "discoverer",
76 + "services"
77 + ]
78 + },
79 + "uiSchema": {
80 + "uiOptions": {
81 + "fullPage": true
82 + },
83 + "ui:flavour": "tabs",
84 + "ui:options": {
85 + "tabs": [
86 + {
87 + "title": "Base",
88 + "fields": [
89 + "name",
90 + "discoverer"
91 + ]
92 + },
93 + {
94 + "title": "Services",
95 + "fields": [
96 + "services"
97 + ]
98 + }
99 + ]
100 + },
101 + "discoverer": {
102 + "docker": {
103 + "address": {
104 + "ui:placeholder": "unix:///var/run/docker.sock",
105 + "ui:help": "Examples: unix:///var/run/docker.sock, tcp://localhost:2375"
106 + },
107 + "timeout": {
108 + "ui:placeholder": "2",
109 + "ui:help": "Value in seconds. Examples: 2, 5, 0.5"
110 + }
111 + }
112 + },
113 + "services": {
114 + "ui:listFlavour": "list",
115 + "items": {
116 + "id": {
117 + "ui:placeholder": "nginx-container",
118 + "ui:help": "Use descriptive names like 'nginx-container', 'mysql-db', 'redis-cache'"
119 + },
120 + "match": {
121 + "ui:widget": "textarea",
122 + "ui:placeholder": "{{ glob .Image \"*nginx*\" }}",
123 + "ui:help": "| Field | Description |\n|-------|-------------|\n| `.ID` | Container ID |\n| `.Name` | Container name |\n| `.Image` | Image name |\n| `.Command` | Container command |\n| `.Labels` | Labels (map) |\n| `.PrivatePort` | Container port |\n| `.PublicPort` | Host port |\n| `.PublicPortIP` | Host IP |\n| `.PortProtocol` | Port protocol |\n| `.NetworkMode` | Network mode |\n| `.NetworkDriver` | Network driver |\n| `.IPAddress` | Container IP |\n| `.Address` | IP:Port combined |\n\n**Functions:** eq, ne, glob, regexp, and, or, not\n\n**Labels access:** {{ index .Labels \"key\" }}"
124 + },
125 + "config_template": {
126 + "ui:widget": "textarea",
127 + "ui:placeholder": "module: nginx\nname: {{.Name}}\nurl: http://{{.Address}}/stub_status"
128 + }
129 + }
130 + }
131 + }
132 +}
src/go/plugin/go.d/agent/discovery/sd/config_schema_k8s.json new
+177
@@ -0,0 +1,177 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "Kubernetes Service Discovery",
5 + "description": "Discovers services running in Kubernetes cluster.",
6 + "type": "object",
7 + "properties": {
8 + "name": {
9 + "title": "Name",
10 + "description": "Pipeline name (must be unique).",
11 + "type": "string",
12 + "minLength": 1
13 + },
14 + "discoverer": {
15 + "title": "Discoverer",
16 + "type": "object",
17 + "properties": {
18 + "k8s": {
19 + "title": "Kubernetes",
20 + "type": "array",
21 + "minItems": 1,
22 + "items": {
23 + "type": "object",
24 + "properties": {
25 + "role": {
26 + "title": "Role",
27 + "description": "Kubernetes resource role to discover.",
28 + "type": "string",
29 + "enum": [
30 + "pod",
31 + "service"
32 + ],
33 + "default": "pod"
34 + },
35 + "namespaces": {
36 + "title": "Namespaces",
37 + "description": "Namespaces to watch (empty = all namespaces).",
38 + "type": "array",
39 + "items": {
40 + "type": "string"
41 + }
42 + },
43 + "selector": {
44 + "title": "Selector",
45 + "description": "Label and field selectors for filtering resources.",
46 + "type": "object",
47 + "properties": {
48 + "label": {
49 + "title": "Label selector",
50 + "description": "Label selector (e.g., 'app=nginx').",
51 + "type": "string"
52 + },
53 + "field": {
54 + "title": "Field selector",
55 + "description": "Field selector (e.g., 'metadata.name=my-pod').",
56 + "type": "string"
57 + }
58 + }
59 + },
60 + "pod": {
61 + "title": "Pod options",
62 + "description": "Pod-specific options.",
63 + "type": "object",
64 + "properties": {
65 + "local_mode": {
66 + "title": "Local mode",
67 + "description": "Only discover pods on the same node as the agent.",
68 + "type": "boolean",
69 + "default": false
70 + }
71 + }
72 + }
73 + },
74 + "required": [
75 + "role"
76 + ]
77 + }
78 + }
79 + },
80 + "required": [
81 + "k8s"
82 + ]
83 + },
84 + "services": {
85 + "title": "Service rules",
86 + "description": "- Match discovered Kubernetes resources and generate collector configurations.\n- Each rule specifies match criteria and a config template.\n- When a pod or service matches, a data collection job is created.",
87 + "type": "array",
88 + "minItems": 1,
89 + "items": {
90 + "type": "object",
91 + "properties": {
92 + "id": {
93 + "title": "Rule ID",
94 + "description": "Unique identifier for this rule. Used in logs to identify which rule matched a resource.",
95 + "type": "string"
96 + },
97 + "match": {
98 + "title": "Match expression",
99 + "description": "Go template expression that must evaluate to 'true' for the rule to match the discovered pod or service.",
100 + "type": "string"
101 + },
102 + "config_template": {
103 + "title": "Config template",
104 + "description": "**Uses the same fields as match expression**. Go template that generates the data collection job configuration in YAML format. **Must include 'module' and 'name' fields**, plus any module-specific settings.",
105 + "type": "string"
106 + }
107 + },
108 + "required": [
109 + "id",
110 + "match"
111 + ]
112 + }
113 + }
114 + },
115 + "required": [
116 + "name",
117 + "discoverer",
118 + "services"
119 + ]
120 + },
121 + "uiSchema": {
122 + "uiOptions": {
123 + "fullPage": true
124 + },
125 + "ui:flavour": "tabs",
126 + "ui:options": {
127 + "tabs": [
128 + {
129 + "title": "Base",
130 + "fields": [
131 + "name",
132 + "discoverer"
133 + ]
134 + },
135 + {
136 + "title": "Services",
137 + "fields": [
138 + "services"
139 + ]
140 + }
141 + ]
142 + },
143 + "discoverer": {
144 + "k8s": {
145 + "ui:listFlavour": "list",
146 + "items": {
147 + "selector": {
148 + "label": {
149 + "ui:placeholder": "app=nginx"
150 + },
151 + "field": {
152 + "ui:placeholder": "metadata.name=my-pod"
153 + }
154 + }
155 + }
156 + }
157 + },
158 + "services": {
159 + "ui:listFlavour": "list",
160 + "items": {
161 + "id": {
162 + "ui:placeholder": "nginx-pods",
163 + "ui:help": "Use descriptive names like 'nginx-pods', 'mysql-service', 'prometheus-metrics'"
164 + },
165 + "match": {
166 + "ui:widget": "textarea",
167 + "ui:placeholder": "{{ eq .Namespace \"default\" }}",
168 + "ui:help": "**Pod role fields:**\n| Field | Description |\n|-------|-------------|\n| `.Address` | Pod IP:Port |\n| `.Namespace` | Pod namespace |\n| `.Name` | Pod name |\n| `.Annotations` | Annotations (map) |\n| `.Labels` | Labels (map) |\n| `.NodeName` | Node name |\n| `.PodIP` | Pod IP |\n| `.ControllerName` | Controller name |\n| `.ControllerKind` | Controller kind |\n| `.ContName` | Container name |\n| `.Image` | Container image |\n| `.Env` | Env vars (map) |\n| `.Port` | Container port |\n| `.PortName` | Port name |\n| `.PortProtocol` | Port protocol |\n\n**Service role fields:**\n| Field | Description |\n|-------|-------------|\n| `.Address` | ClusterIP:Port |\n| `.Namespace` | Service namespace |\n| `.Name` | Service name |\n| `.Annotations` | Annotations (map) |\n| `.Labels` | Labels (map) |\n| `.Port` | Service port |\n| `.PortName` | Port name |\n| `.PortProtocol` | Port protocol |\n| `.ClusterIP` | Cluster IP |\n| `.ExternalName` | External name |\n\n**Functions:** eq, ne, glob, regexp, and, or, not\n\n**Map access:** {{ index .Labels \"key\" }}"
169 + },
170 + "config_template": {
171 + "ui:widget": "textarea",
172 + "ui:placeholder": "module: nginx\nname: {{.Namespace}}-{{.Name}}\nurl: http://{{.Address}}/stub_status"
173 + }
174 + }
175 + }
176 + }
177 +}
src/go/plugin/go.d/agent/discovery/sd/config_schema_net_listeners.json new
+133
@@ -0,0 +1,133 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "Net Listeners Service Discovery",
5 + "description": "Discovers services by scanning local listening network ports.",
6 + "type": "object",
7 + "properties": {
8 + "name": {
9 + "title": "Name",
10 + "description": "Pipeline name (must be unique).",
11 + "type": "string",
12 + "minLength": 1
13 + },
14 + "discoverer": {
15 + "title": "Discoverer",
16 + "type": "object",
17 + "properties": {
18 + "net_listeners": {
19 + "title": "Net Listeners",
20 + "type": "object",
21 + "properties": {
22 + "interval": {
23 + "title": "Scan interval",
24 + "description": "How often to scan for listeners.",
25 + "type": "string",
26 + "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m|h|d|w|mo|y)?$",
27 + "default": "2m"
28 + },
29 + "timeout": {
30 + "title": "Timeout",
31 + "description": "Timeout for local listener discovery, in seconds.",
32 + "type": "number",
33 + "minimum": 0.1,
34 + "default": 5
35 + }
36 + }
37 + }
38 + },
39 + "required": [
40 + "net_listeners"
41 + ]
42 + },
43 + "services": {
44 + "title": "Service rules",
45 + "description": "- Match discovered network listeners and generate collector configurations.\n- Each rule specifies match criteria and a config template.\n- When a listener matches, a data collection job is created.",
46 + "type": "array",
47 + "minItems": 1,
48 + "items": {
49 + "type": "object",
50 + "properties": {
51 + "id": {
52 + "title": "Rule ID",
53 + "description": "Unique identifier for this rule. Used in logs to identify which rule matched a service.",
54 + "type": "string"
55 + },
56 + "match": {
57 + "title": "Match expression",
58 + "description": "Go template expression that must evaluate to 'true' for the rule to match the discovered service.",
59 + "type": "string"
60 + },
61 + "config_template": {
62 + "title": "Config template",
63 + "description": "**Uses the same fields as match expression**. Go template that generates the data collection job configuration in YAML format. **Must include 'module' and 'name' fields**, plus any module-specific settings.",
64 + "type": "string"
65 + }
66 + },
67 + "required": [
68 + "id",
69 + "match"
70 + ]
71 + }
72 + }
73 + },
74 + "required": [
75 + "name",
76 + "discoverer",
77 + "services"
78 + ]
79 + },
80 + "uiSchema": {
81 + "uiOptions": {
82 + "fullPage": true
83 + },
84 + "ui:flavour": "tabs",
85 + "ui:options": {
86 + "tabs": [
87 + {
88 + "title": "Base",
89 + "fields": [
90 + "name",
91 + "discoverer"
92 + ]
93 + },
94 + {
95 + "title": "Services",
96 + "fields": [
97 + "services"
98 + ]
99 + }
100 + ]
101 + },
102 + "discoverer": {
103 + "net_listeners": {
104 + "interval": {
105 + "ui:placeholder": "2m",
106 + "ui:help": "Examples: 30s, 2m, 5m, 1h"
107 + },
108 + "timeout": {
109 + "ui:placeholder": "5",
110 + "ui:help": "Value in seconds. Examples: 5, 10, 0.5"
111 + }
112 + }
113 + },
114 + "services": {
115 + "ui:listFlavour": "list",
116 + "items": {
117 + "id": {
118 + "ui:placeholder": "nginx-http",
119 + "ui:help": "Use descriptive names like 'nginx-http', 'mysql-default', 'redis-local'"
120 + },
121 + "match": {
122 + "ui:widget": "textarea",
123 + "ui:placeholder": "{{ eq .Comm \"nginx\" }}",
124 + "ui:help": "| Field | Description |\n|-------|-------------|\n| `.Protocol` | TCP, TCP6, UDP, UDP6 |\n| `.IPAddress` | IP address |\n| `.Port` | Port number |\n| `.Address` | IP:Port combined |\n| `.Comm` | Process name |\n| `.Cmdline` | Full command line |\n\n**Functions:** eq, ne, glob, regexp, and, or, not"
125 + },
126 + "config_template": {
127 + "ui:widget": "textarea",
128 + "ui:placeholder": "module: nginx\nname: {{.Name}}-{{.Address}}\nurl: http://{{.Address}}/stub_status"
129 + }
130 + }
131 + }
132 + }
133 +}
src/go/plugin/go.d/agent/discovery/sd/config_schema_snmp.json new
+295
@@ -0,0 +1,295 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "SNMP Device Discovery",
5 + "description": "Discovers SNMP devices by scanning network subnets.",
6 + "type": "object",
7 + "properties": {
8 + "name": {
9 + "title": "Name",
10 + "description": "Pipeline name (must be unique).",
11 + "type": "string",
12 + "minLength": 1
13 + },
14 + "discoverer": {
15 + "title": "Discoverer",
16 + "type": "object",
17 + "properties": {
18 + "snmp": {
19 + "title": "SNMP",
20 + "type": "object",
21 + "properties": {
22 + "rescan_interval": {
23 + "title": "Rescan interval",
24 + "description": "How often to scan networks for devices.",
25 + "type": "string",
26 + "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m|h|d|w|mo|y)?$",
27 + "default": "30m"
28 + },
29 + "timeout": {
30 + "title": "Timeout",
31 + "description": "Timeout for SNMP device responses, in seconds.",
32 + "type": "number",
33 + "minimum": 0.1,
34 + "default": 1
35 + },
36 + "device_cache_ttl": {
37 + "title": "Device cache TTL",
38 + "description": "How long to cache discovery results.",
39 + "type": "string",
40 + "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m|h|d|w|mo|y)?$",
41 + "default": "12h"
42 + },
43 + "parallel_scans_per_network": {
44 + "title": "Parallel scans",
45 + "description": "Concurrent IPs to scan per subnet.",
46 + "type": "integer",
47 + "minimum": 1,
48 + "maximum": 256,
49 + "default": 32
50 + },
51 + "credentials": {
52 + "title": "Credentials",
53 + "description": "SNMP credentials for authentication.",
54 + "type": "array",
55 + "minItems": 1,
56 + "items": {
57 + "type": "object",
58 + "properties": {
59 + "name": {
60 + "title": "Name",
61 + "description": "Credential identifier (referenced by networks).",
62 + "type": "string"
63 + },
64 + "version": {
65 + "title": "SNMP version",
66 + "description": "SNMP protocol version.",
67 + "type": "string",
68 + "enum": [
69 + "1",
70 + "2",
71 + "2c",
72 + "3"
73 + ],
74 + "default": "2c"
75 + },
76 + "community": {
77 + "title": "Community",
78 + "description": "Community string (for SNMP v1/v2c).",
79 + "type": "string",
80 + "default": "public"
81 + },
82 + "username": {
83 + "title": "Username",
84 + "description": "Username (for SNMPv3).",
85 + "type": "string"
86 + },
87 + "security_level": {
88 + "title": "Security level",
89 + "description": "Security level (for SNMPv3).",
90 + "type": "string",
91 + "enum": [
92 + "noAuthNoPriv",
93 + "authNoPriv",
94 + "authPriv"
95 + ]
96 + },
97 + "auth_protocol": {
98 + "title": "Auth protocol",
99 + "description": "Authentication protocol (for SNMPv3).",
100 + "type": "string",
101 + "enum": [
102 + "md5",
103 + "sha",
104 + "sha224",
105 + "sha256",
106 + "sha384",
107 + "sha512"
108 + ]
109 + },
110 + "auth_password": {
111 + "title": "Auth password",
112 + "description": "Authentication passphrase (for SNMPv3).",
113 + "type": "string"
114 + },
115 + "priv_protocol": {
116 + "title": "Privacy protocol",
117 + "description": "Privacy protocol (for SNMPv3).",
118 + "type": "string",
119 + "enum": [
120 + "des",
121 + "aes",
122 + "aes192",
123 + "aes256",
124 + "aes192c",
125 + "aes256c"
126 + ]
127 + },
128 + "priv_password": {
129 + "title": "Privacy password",
130 + "description": "Privacy passphrase (for SNMPv3).",
131 + "type": "string"
132 + }
133 + },
134 + "required": [
135 + "name",
136 + "version"
137 + ]
138 + }
139 + },
140 + "networks": {
141 + "title": "Networks",
142 + "description": "Network subnets to scan.",
143 + "type": "array",
144 + "minItems": 1,
145 + "items": {
146 + "type": "object",
147 + "properties": {
148 + "subnet": {
149 + "title": "Subnet",
150 + "description": "IP range to scan (e.g., '192.168.1.0/24', '10.0.0.1-10.0.0.50').",
151 + "type": "string"
152 + },
153 + "credential": {
154 + "title": "Credential",
155 + "description": "Name of credential to use for this network.",
156 + "type": "string"
157 + }
158 + },
159 + "required": [
160 + "subnet",
161 + "credential"
162 + ]
163 + }
164 + }
165 + },
166 + "required": [
167 + "credentials",
168 + "networks"
169 + ]
170 + }
171 + },
172 + "required": [
173 + "snmp"
174 + ]
175 + },
176 + "services": {
177 + "title": "Service rules",
178 + "description": "- Match discovered SNMP devices and generate collector configurations.\n- Each rule specifies match criteria and a config template.\n- When a device matches, a data collection job is created.\n- The **default service rule** is sufficient for most cases. It creates an SNMP data collection job for each discovered device and handles both SNMPv2 and SNMPv3.",
179 + "type": "array",
180 + "minItems": 1,
181 + "items": {
182 + "type": "object",
183 + "properties": {
184 + "id": {
185 + "title": "Rule ID",
186 + "description": "Unique identifier for this rule. Used in logs to identify which rule matched a device.",
187 + "type": "string",
188 + "default": "snmp"
189 + },
190 + "match": {
191 + "title": "Match expression",
192 + "description": "Go template expression that must evaluate to 'true' for the rule to match the discovered SNMP device.",
193 + "type": "string",
194 + "default": "{{ true }}"
195 + },
196 + "config_template": {
197 + "title": "Config template",
198 + "description": "**Uses the same fields as match expression**. Go template that generates the data collection job configuration in YAML format. **Must include 'module' and 'name' fields**, plus any module-specific settings.",
199 + "type": "string",
200 + "default": "{{- if .SysInfo.Name }}\nname: {{ .SysInfo.Name }}-ip-{{ .IPAddress }}\n{{- else }}\nname: ip-{{ .IPAddress }}\n{{- end }}\nhostname: {{ .IPAddress }}\noptions:\n version: {{ .Credential.Version }}\n{{- if eq .Credential.Version \"1\" \"2\" \"2c\" }}\ncommunity: {{ .Credential.Community }}\n{{- else }}\nuser:\n name: {{ .Credential.UserName }}\n level: {{ .Credential.SecurityLevel }}\n auth_proto: {{ .Credential.AuthProtocol }}\n auth_key: {{ .Credential.AuthPassphrase }}\n priv_proto: {{ .Credential.PrivacyProtocol }}\n priv_key: {{ .Credential.PrivacyPassphrase }}\n{{- end }}"
201 + }
202 + },
203 + "required": [
204 + "id",
205 + "match"
206 + ]
207 + }
208 + }
209 + },
210 + "required": [
211 + "name",
212 + "discoverer",
213 + "services"
214 + ]
215 + },
216 + "uiSchema": {
217 + "uiOptions": {
218 + "fullPage": true
219 + },
220 + "ui:flavour": "tabs",
221 + "ui:options": {
222 + "tabs": [
223 + {
224 + "title": "Base",
225 + "fields": [
226 + "name",
227 + "discoverer"
228 + ]
229 + },
230 + {
231 + "title": "Services",
232 + "fields": [
233 + "services"
234 + ]
235 + }
236 + ]
237 + },
238 + "discoverer": {
239 + "snmp": {
240 + "rescan_interval": {
241 + "ui:placeholder": "30m",
242 + "ui:help": "Examples: 30m, 1h, 2h"
243 + },
244 + "timeout": {
245 + "ui:placeholder": "1",
246 + "ui:help": "Value in seconds. Examples: 1, 5, 0.5"
247 + },
248 + "device_cache_ttl": {
249 + "ui:placeholder": "12h",
250 + "ui:help": "Examples: 12h, 24h, 1d, 1w"
251 + },
252 + "credentials": {
253 + "ui:listFlavour": "list",
254 + "items": {
255 + "community": {
256 + "ui:widget": "password"
257 + },
258 + "auth_password": {
259 + "ui:widget": "password"
260 + },
261 + "priv_password": {
262 + "ui:widget": "password"
263 + }
264 + }
265 + },
266 + "networks": {
267 + "ui:listFlavour": "list",
268 + "items": {
269 + "subnet": {
270 + "ui:placeholder": "192.168.1.0/24"
271 + }
272 + }
273 + }
274 + }
275 + },
276 + "services": {
277 + "ui:listFlavour": "list",
278 + "items": {
279 + "id": {
280 + "ui:placeholder": "snmp",
281 + "ui:help": "Use descriptive names like 'snmp', 'cisco-switches', 'hp-printers'"
282 + },
283 + "match": {
284 + "ui:widget": "textarea",
285 + "ui:placeholder": "{{ true }}",
286 + "ui:help": "| Field | Description |\n|-------|-------------|\n| `.IPAddress` | Device IP address |\n| `.SysInfo.Descr` | System description |\n| `.SysInfo.Contact` | System contact |\n| `.SysInfo.Name` | System name |\n| `.SysInfo.Location` | System location |\n| `.SysInfo.Organization` | Organization |\n| `.SysInfo.Vendor` | Device vendor |\n| `.SysInfo.Category` | Device category |\n| `.SysInfo.Model` | Device model |\n| `.Credential.Name` | Credential name |\n| `.Credential.Version` | SNMP version |\n\n**Functions:** eq, ne, glob, regexp, and, or, not"
287 + },
288 + "config_template": {
289 + "ui:widget": "textarea",
290 + "ui:placeholder": "{{- if .SysInfo.Name }}\nname: {{ .SysInfo.Name }}-ip-{{ .IPAddress }}\n{{- else }}\nname: ip-{{ .IPAddress }}\n{{- end }}\nhostname: {{ .IPAddress }}\noptions:\n version: {{ .Credential.Version }}\n{{- if eq .Credential.Version \"1\" \"2\" \"2c\" }}\ncommunity: {{ .Credential.Community }}\n{{- else }}\nuser:\n name: {{ .Credential.UserName }}\n level: {{ .Credential.SecurityLevel }}\n auth_proto: {{ .Credential.AuthProtocol }}\n auth_key: {{ .Credential.AuthPassphrase }}\n priv_proto: {{ .Credential.PrivacyProtocol }}\n priv_key: {{ .Credential.PrivacyPassphrase }}\n{{- end }}"
291 + }
292 + }
293 + }
294 + }
295 +}
src/go/plugin/go.d/agent/discovery/sd/discoverer/dockersd/docker.go
+6 -31
@@ -18,15 +18,9 @@ import (
18
19 typesContainer "github.com/docker/docker/api/types/container"
20 docker "github.com/docker/docker/client"
21 - "github.com/gohugoio/hashstructure"
21 )
22
23 func NewDiscoverer(cfg Config) (*Discoverer, error) {
25 - tags, err := model.ParseTags(cfg.Tags)
26 - if err != nil {
27 - return nil, fmt.Errorf("parse tags: %v", err)
28 - }
29 -
24 d := &Discoverer{
25 Logger: logger.New().With(
26 slog.String("component", "service discovery"),
@@ -48,9 +42,7 @@ func NewDiscoverer(cfg Config) (*Discoverer, error) {
42 d.addr = addr
43 }
44
51 - d.Tags().Merge(tags)
52 -
53 - if cfg.Timeout.Duration().Seconds() != 0 {
45 + if cfg.Timeout.Duration() > 0 {
46 d.timeout = cfg.Timeout.Duration()
47 }
48 if cfg.Address != "" {
@@ -61,11 +53,10 @@ func NewDiscoverer(cfg Config) (*Discoverer, error) {
53 }
54
55 type Config struct {
64 - Source string
56 + Source string `yaml:"-" json:"-"`
57
66 - Tags string `yaml:"tags"`
67 - Address string `yaml:"address"`
68 - Timeout confopt.Duration `yaml:"timeout"`
58 + Address string `yaml:"address,omitempty" json:"address,omitempty"`
59 + Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
60 }
61
62 type (
@@ -187,7 +178,7 @@ func (d *Discoverer) buildTargetGroup(cntr typesContainer.Summary) model.TargetG
178 Name: strings.TrimPrefix(cntr.Names[0], "/"),
179 Image: cntr.Image,
180 Command: cntr.Command,
190 - Labels: mapAny(cntr.Labels),
181 + Labels: model.MapAny(cntr.Labels),
182 PrivatePort: strconv.Itoa(int(port.PrivatePort)),
183 PublicPort: strconv.Itoa(int(port.PublicPort)),
184 PublicPortIP: port.IP,
@@ -198,13 +189,12 @@ func (d *Discoverer) buildTargetGroup(cntr typesContainer.Summary) model.TargetG
189 }
190 tgt.Address = net.JoinHostPort(tgt.IPAddress, tgt.PrivatePort)
191
201 - hash, err := calcHash(tgt)
192 + hash, err := model.CalcHash(tgt)
193 if err != nil {
194 continue
195 }
196
197 tgt.hash = hash
207 - tgt.Tags().Merge(d.Tags())
198
199 tgg.targets = append(tgg.targets, tgt)
200 }
@@ -223,18 +213,3 @@ func cntrSource(cntr typesContainer.Summary) string {
213 name := strings.TrimPrefix(cntr.Names[0], "/")
214 return fmt.Sprintf("discoverer=docker,container=%s,image=%s", name, cntr.Image)
215 }
226 -
227 -func calcHash(obj any) (uint64, error) {
228 - return hashstructure.Hash(obj, nil)
229 -}
230 -
231 -func mapAny(src map[string]string) map[string]any {
232 - if src == nil {
233 - return nil
234 - }
235 - m := make(map[string]any, len(src))
236 - for k, v := range src {
237 - m[k] = v
238 - }
239 - return m
240 -}
src/go/plugin/go.d/agent/discovery/sd/discoverer/dockersd/dockerd_test.go
+4 -6
@@ -36,7 +36,7 @@ func TestDiscoverer_Discover(t *testing.T) {
36 Name: nginx1.Names[0][1:],
37 Image: nginx1.Image,
38 Command: nginx1.Command,
39 - Labels: mapAny(nginx1.Labels),
39 + Labels: model.MapAny(nginx1.Labels),
40 PrivatePort: "80",
41 PublicPort: "8080",
42 PublicPortIP: "0.0.0.0",
@@ -56,7 +56,7 @@ func TestDiscoverer_Discover(t *testing.T) {
56 Name: nginx2.Names[0][1:],
57 Image: nginx2.Image,
58 Command: nginx2.Command,
59 - Labels: mapAny(nginx2.Labels),
59 + Labels: model.MapAny(nginx2.Labels),
60 PrivatePort: "80",
61 PublicPort: "8080",
62 PublicPortIP: "0.0.0.0",
@@ -98,7 +98,7 @@ func TestDiscoverer_Discover(t *testing.T) {
98 Name: nginx2.Names[0][1:],
99 Image: nginx2.Image,
100 Command: nginx2.Command,
101 - Labels: mapAny(nginx2.Labels),
101 + Labels: model.MapAny(nginx2.Labels),
102 PrivatePort: "80",
103 PublicPort: "8080",
104 PublicPortIP: "0.0.0.0",
@@ -156,8 +156,6 @@ func prepareNginxContainer(name string) typesContainer.Summary {
156 }
157
158 func withHash(tgt *target) *target {
159 - tgt.hash, _ = calcHash(tgt)
160 - tags, _ := model.ParseTags("docker")
161 - tgt.Tags().Merge(tags)
159 + tgt.hash, _ = model.CalcHash(tgt)
160 return tgt
161 }
src/go/plugin/go.d/agent/discovery/sd/discoverer/dockersd/sim_test.go
-1
@@ -30,7 +30,6 @@ type discoverySim struct {
30 func (sim *discoverySim) run(t *testing.T) {
31 d, err := NewDiscoverer(Config{
32 Source: "",
33 - Tags: "docker",
33 })
34 require.NoError(t, err)
35
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/config.go
+9 -14
@@ -3,24 +3,22 @@
3 package k8ssd
4
5 import (
6 - "errors"
6 "fmt"
7 )
8
9 type Config struct {
11 - Source string `yaml:"-"`
10 + Source string `yaml:"-" json:"-"`
11
13 - APIServer string `yaml:"api_server"` // TODO: not used
14 - Role string `yaml:"role"`
15 - Tags string `yaml:"tags"`
16 - Namespaces []string `yaml:"namespaces"`
12 + APIServer string `yaml:"api_server,omitempty" json:"-"` // TODO: not used
13 + Role string `yaml:"role,omitempty" json:"role,omitempty"`
14 + Namespaces []string `yaml:"namespaces,omitempty" json:"namespaces,omitempty"`
15 Selector struct {
18 - Label string `yaml:"label"`
19 - Field string `yaml:"field"`
20 - } `yaml:"selector"`
16 + Label string `yaml:"label,omitempty" json:"label,omitempty"`
17 + Field string `yaml:"field,omitempty" json:"field,omitempty"`
18 + } `yaml:"selector,omitempty" json:"selector,omitempty"`
19 Pod struct {
22 - LocalMode bool `yaml:"local_mode"`
23 - } `yaml:"pod"`
20 + LocalMode bool `yaml:"local_mode,omitempty" json:"local_mode,omitempty"`
21 + } `yaml:"pod,omitempty" json:"pod,omitempty"`
22 }
23
24 func validateConfig(cfg Config) error {
@@ -29,8 +27,5 @@ func validateConfig(cfg Config) error {
27 default:
28 return fmt.Errorf("unknown role: '%s'", cfg.Role)
29 }
32 - if cfg.Tags == "" {
33 - return errors.New("'tags' not set")
34 - }
30 return nil
31 }
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/kubernetes.go
+1 -25
@@ -15,7 +15,6 @@ import (
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
16 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/k8sclient"
17
18 - "github.com/gohugoio/hashstructure"
18 corev1 "k8s.io/api/core/v1"
19 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20 "k8s.io/apimachinery/pkg/runtime"
@@ -41,16 +40,11 @@ var log = logger.New().With(
40 slog.String("discoverer", "kubernetes"),
41 )
42
44 -func NewKubeDiscoverer(cfg Config) (*KubeDiscoverer, error) {
43 +func NewDiscoverer(cfg Config) (*KubeDiscoverer, error) {
44 if err := validateConfig(cfg); err != nil {
45 return nil, fmt.Errorf("config validation: %v", err)
46 }
47
49 - tags, err := model.ParseTags(cfg.Tags)
50 - if err != nil {
51 - return nil, fmt.Errorf("parse tags: %v", err)
52 - }
53 -
48 client, err := k8sclient.New("Netdata/service-td")
49 if err != nil {
50 return nil, fmt.Errorf("create clientset: %v", err)
@@ -74,7 +68,6 @@ func NewKubeDiscoverer(cfg Config) (*KubeDiscoverer, error) {
68 Logger: log,
69 cfgSource: cfg.Source,
70 client: client,
77 - tags: tags,
71 role: role(cfg.Role),
72 namespaces: ns,
73 selectorLabel: cfg.Selector.Label,
@@ -93,7 +86,6 @@ type KubeDiscoverer struct {
86
87 client kubernetes.Interface
88
96 - tags model.Tags
89 role role
90 namespaces []string
91 selectorLabel string
@@ -216,7 +208,6 @@ func (d *KubeDiscoverer) setupPodDiscoverer(ctx context.Context, ns string) *pod
208 cache.NewSharedInformer(cmapLW, &corev1.ConfigMap{}, resyncPeriod),
209 cache.NewSharedInformer(secretLW, &corev1.Secret{}, resyncPeriod),
210 )
219 - td.Tags().Merge(d.tags)
211
212 return td
213 }
@@ -240,7 +231,6 @@ func (d *KubeDiscoverer) setupServiceDiscoverer(ctx context.Context, namespace s
231 inf := cache.NewSharedInformer(svcLW, &corev1.Service{}, resyncPeriod)
232
233 td := newServiceDiscoverer(inf)
243 - td.Tags().Merge(d.tags)
234
235 return td
236 }
@@ -253,20 +243,6 @@ func enqueue(queue *workqueue.Typed[any], obj any) {
243 queue.Add(key)
244 }
245
256 -func send(ctx context.Context, in chan<- []model.TargetGroup, tgg model.TargetGroup) {
257 - if tgg == nil {
258 - return
259 - }
260 - select {
261 - case <-ctx.Done():
262 - case in <- []model.TargetGroup{tgg}:
263 - }
264 -}
265 -
266 -func calcHash(obj any) (uint64, error) {
267 - return hashstructure.Hash(obj, nil)
268 -}
269 -
246 func joinSelectors(srs ...string) string {
247 var i int
248 for _, v := range srs {
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/kubernetes_test.go
+4 -8
@@ -18,8 +18,6 @@ import (
18 "k8s.io/client-go/kubernetes/fake"
19 )
20
21 -var discoveryTags, _ = model.ParseTags("k8s")
22 -
21 func TestMain(m *testing.M) {
22 _ = os.Setenv(envNodeName, "m01")
23 _ = os.Setenv(k8sclient.EnvFakeClient, "true")
@@ -36,11 +34,11 @@ func TestNewKubeDiscoverer(t *testing.T) {
34 }{
35 "pod role config": {
36 wantErr: false,
39 - cfg: Config{Role: string(rolePod), Tags: "k8s"},
37 + cfg: Config{Role: string(rolePod)},
38 },
39 "service role config": {
40 wantErr: false,
43 - cfg: Config{Role: string(roleService), Tags: "k8s"},
41 + cfg: Config{Role: string(roleService)},
42 },
43 "empty config": {
44 wantErr: true,
@@ -49,7 +47,7 @@ func TestNewKubeDiscoverer(t *testing.T) {
47 }
48 for name, test := range tests {
49 t.Run(name, func(t *testing.T) {
52 - disc, err := NewKubeDiscoverer(test.cfg)
50 + disc, err := NewDiscoverer(test.cfg)
51
52 if test.wantErr {
53 assert.Error(t, err)
@@ -135,10 +133,8 @@ func TestKubeDiscoverer_Discover(t *testing.T) {
133
134 func prepareDiscoverer(role role, namespaces []string, objects ...runtime.Object) (*KubeDiscoverer, kubernetes.Interface) {
135 client := fake.NewClientset(objects...)
138 - tags, _ := model.ParseTags("k8s")
136 disc := &KubeDiscoverer{
137 cfgSource: "test=test",
141 - tags: tags,
138 role: role,
139 namespaces: namespaces,
140 client: client,
@@ -153,7 +149,7 @@ func newNamespace(name string) *corev1.Namespace {
149 }
150
151 func mustCalcHash(obj any) uint64 {
156 - hash, err := calcHash(obj)
152 + hash, err := model.CalcHash(obj)
153 if err != nil {
154 panic(fmt.Sprintf("hash calculation: %v", err))
155 }
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/pod.go
+10 -25
@@ -136,7 +136,7 @@ func (p *podDiscoverer) handleQueueItem(ctx context.Context, in chan<- []model.T
136
137 if !ok {
138 tgg := &podTargetGroup{source: podSourceFromNsName(namespace, name)}
139 - send(ctx, in, tgg)
139 + model.SendTargetGroup(ctx, in, tgg)
140 return
141 }
142
@@ -147,11 +147,7 @@ func (p *podDiscoverer) handleQueueItem(ctx context.Context, in chan<- []model.T
147
148 tgg := p.buildTargetGroup(pod)
149
150 - for _, tgt := range tgg.Targets() {
151 - tgt.Tags().Merge(p.Tags())
152 - }
153 -
154 - send(ctx, in, tgg)
150 + model.SendTargetGroup(ctx, in, tgg)
151
152 }
153
@@ -186,17 +182,17 @@ func (p *podDiscoverer) buildTargets(pod *corev1.Pod) (targets []model.Target) {
182 Address: pod.Status.PodIP,
183 Namespace: pod.Namespace,
184 Name: pod.Name,
189 - Annotations: mapAny(pod.Annotations),
190 - Labels: mapAny(pod.Labels),
185 + Annotations: model.MapAny(pod.Annotations),
186 + Labels: model.MapAny(pod.Labels),
187 NodeName: pod.Spec.NodeName,
188 PodIP: pod.Status.PodIP,
189 ControllerName: name,
190 ControllerKind: kind,
191 ContName: container.Name,
192 Image: container.Image,
197 - Env: mapAny(env),
193 + Env: model.MapAny(env),
194 }
199 - hash, err := calcHash(tgt)
195 + hash, err := model.CalcHash(tgt)
196 if err != nil {
197 continue
198 }
@@ -211,20 +207,20 @@ func (p *podDiscoverer) buildTargets(pod *corev1.Pod) (targets []model.Target) {
207 Address: net.JoinHostPort(pod.Status.PodIP, portNum),
208 Namespace: pod.Namespace,
209 Name: pod.Name,
214 - Annotations: mapAny(pod.Annotations),
215 - Labels: mapAny(pod.Labels),
210 + Annotations: model.MapAny(pod.Annotations),
211 + Labels: model.MapAny(pod.Labels),
212 NodeName: pod.Spec.NodeName,
213 PodIP: pod.Status.PodIP,
214 ControllerName: name,
215 ControllerKind: kind,
216 ContName: container.Name,
217 Image: container.Image,
222 - Env: mapAny(env),
218 + Env: model.MapAny(env),
219 Port: portNum,
220 PortName: port.Name,
221 PortProtocol: string(port.Protocol),
222 }
227 - hash, err := calcHash(tgt)
223 + hash, err := model.CalcHash(tgt)
224 if err != nil {
225 continue
226 }
@@ -422,14 +418,3 @@ func isVar(name string) bool {
418 // variables.
419 return strings.IndexByte(name, '$') != -1
420 }
425 -
426 -func mapAny(src map[string]string) map[string]any {
427 - if src == nil {
428 - return nil
429 - }
430 - m := make(map[string]any, len(src))
431 - for k, v := range src {
432 - m[k] = v
433 - }
434 - return m
435 -}
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/pod_test.go
+3 -4
@@ -615,8 +615,8 @@ func preparePodTargetGroup(pod *corev1.Pod) *podTargetGroup {
615 Address: net.JoinHostPort(pod.Status.PodIP, portNum),
616 Namespace: pod.Namespace,
617 Name: pod.Name,
618 - Annotations: mapAny(pod.Annotations),
619 - Labels: mapAny(pod.Labels),
618 + Annotations: model.MapAny(pod.Annotations),
619 + Labels: model.MapAny(pod.Labels),
620 NodeName: pod.Spec.NodeName,
621 PodIP: pod.Status.PodIP,
622 ControllerName: "netdata-test",
@@ -629,7 +629,6 @@ func preparePodTargetGroup(pod *corev1.Pod) *podTargetGroup {
629 PortProtocol: string(port.Protocol),
630 }
631 tgt.hash = mustCalcHash(tgt)
632 - tgt.Tags().Merge(discoveryTags)
632
633 tgg.targets = append(tgg.targets, tgt)
634 }
@@ -642,7 +641,7 @@ func preparePodTargetGroupWithEnv(pod *corev1.Pod, env map[string]string) *podTa
641 tgg := preparePodTargetGroup(pod)
642
643 for _, tgt := range tgg.Targets() {
645 - tgt.(*PodTarget).Env = mapAny(env)
644 + tgt.(*PodTarget).Env = model.MapAny(env)
645 tgt.(*PodTarget).hash = mustCalcHash(tgt)
646 }
647
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/service.go
+5 -9
@@ -125,7 +125,7 @@ func (s *serviceDiscoverer) handleQueueItem(ctx context.Context, in chan<- []mod
125
126 if !exists {
127 tgg := &serviceTargetGroup{source: serviceSourceFromNsName(namespace, name)}
128 - send(ctx, in, tgg)
128 + model.SendTargetGroup(ctx, in, tgg)
129 return
130 }
131
@@ -136,11 +136,7 @@ func (s *serviceDiscoverer) handleQueueItem(ctx context.Context, in chan<- []mod
136
137 tgg := s.buildTargetGroup(svc)
138
139 - for _, tgt := range tgg.Targets() {
140 - tgt.Tags().Merge(s.Tags())
141 - }
142 -
143 - send(ctx, in, tgg)
139 + model.SendTargetGroup(ctx, in, tgg)
140 }
141
142 func (s *serviceDiscoverer) buildTargetGroup(svc *corev1.Service) model.TargetGroup {
@@ -164,8 +160,8 @@ func (s *serviceDiscoverer) buildTargets(svc *corev1.Service) (targets []model.T
160 Address: net.JoinHostPort(svc.Name+"."+svc.Namespace+".svc", portNum),
161 Namespace: svc.Namespace,
162 Name: svc.Name,
167 - Annotations: mapAny(svc.Annotations),
168 - Labels: mapAny(svc.Labels),
163 + Annotations: model.MapAny(svc.Annotations),
164 + Labels: model.MapAny(svc.Labels),
165 Port: portNum,
166 PortName: port.Name,
167 PortProtocol: string(port.Protocol),
@@ -173,7 +169,7 @@ func (s *serviceDiscoverer) buildTargets(svc *corev1.Service) (targets []model.T
169 ExternalName: svc.Spec.ExternalName,
170 Type: string(svc.Spec.Type),
171 }
176 - hash, err := calcHash(tgt)
172 + hash, err := model.CalcHash(tgt)
173 if err != nil {
174 continue
175 }
src/go/plugin/go.d/agent/discovery/sd/discoverer/k8ssd/service_test.go
+2 -3
@@ -440,8 +440,8 @@ func prepareSvcTargetGroup(svc *corev1.Service) *serviceTargetGroup {
440 Address: net.JoinHostPort(svc.Name+"."+svc.Namespace+".svc", portNum),
441 Namespace: svc.Namespace,
442 Name: svc.Name,
443 - Annotations: mapAny(svc.Annotations),
444 - Labels: mapAny(svc.Labels),
443 + Annotations: model.MapAny(svc.Annotations),
444 + Labels: model.MapAny(svc.Labels),
445 Port: portNum,
446 PortName: port.Name,
447 PortProtocol: string(port.Protocol),
@@ -450,7 +450,6 @@ func prepareSvcTargetGroup(svc *corev1.Service) *serviceTargetGroup {
450 Type: string(svc.Spec.Type),
451 }
452 tgt.hash = mustCalcHash(tgt)
453 - tgt.Tags().Merge(discoveryTags)
453 tgg.targets = append(tgg.targets, tgt)
454 }
455
src/go/plugin/go.d/agent/discovery/sd/discoverer/netlistensd/netlisteners.go
+6 -21
@@ -16,8 +16,6 @@ import (
16 "strings"
17 "time"
18
19 - "github.com/gohugoio/hashstructure"
20 -
19 "github.com/netdata/netdata/go/plugins/logger"
20 "github.com/netdata/netdata/go/plugins/pkg/confopt"
21 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
@@ -29,25 +27,19 @@ var (
27 )
28
29 type Config struct {
32 - Source string `yaml:"-"`
33 - Tags string `yaml:"tags"`
30 + Source string `yaml:"-" json:"-"`
31
35 - Interval *confopt.Duration `yaml:"interval"`
36 - Timeout confopt.Duration `yaml:"timeout"`
32 + Interval confopt.LongDuration `yaml:"interval,omitempty" json:"interval,omitempty"`
33 + Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
34 }
35
36 func NewDiscoverer(cfg Config) (*Discoverer, error) {
40 - tags, err := model.ParseTags(cfg.Tags)
41 - if err != nil {
42 - return nil, fmt.Errorf("parse tags: %v", err)
43 - }
44 -
37 interval := time.Minute * 2
46 - if cfg.Interval != nil {
38 + if cfg.Interval.Duration() > 0 {
39 interval = cfg.Interval.Duration()
40 }
41 timeout := time.Second * 5
50 - if cfg.Timeout.Duration() != 0 {
42 + if cfg.Timeout.Duration() > 0 {
43 timeout = cfg.Timeout.Duration()
44 }
45
@@ -65,8 +57,6 @@ func NewDiscoverer(cfg Config) (*Discoverer, error) {
57 started: make(chan struct{}),
58 }
59
68 - d.Tags().Merge(tags)
69 -
60 return d, nil
61 }
62
@@ -246,13 +236,12 @@ func (d *Discoverer) parseLocalListeners(bs []byte) ([]model.Target, error) {
236
237 tgt.Address = net.JoinHostPort(tgt.IPAddress, tgt.Port)
238
249 - hash, err := calcHash(tgt)
239 + hash, err := model.CalcHash(tgt)
240 if err != nil {
241 continue
242 }
243
244 tgt.hash = hash
255 - tgt.Tags().Merge(d.Tags())
245
246 targets = append(targets, tgt)
247 }
@@ -306,7 +295,3 @@ func extractComm(cmdLine string) string {
295 _, comm := filepath.Split(cmdLine)
296 return strings.TrimSuffix(comm, ":")
297 }
309 -
310 -func calcHash(obj any) (uint64, error) {
311 - return hashstructure.Hash(obj, nil)
312 -}
src/go/plugin/go.d/agent/discovery/sd/discoverer/netlistensd/netlisteners_test.go
+1 -3
@@ -162,8 +162,6 @@ func TestDiscoverer_Discover(t *testing.T) {
162 }
163
164 func withHash(l *target) *target {
165 - l.hash, _ = calcHash(l)
166 - tags, _ := model.ParseTags("netlisteners")
167 - l.Tags().Merge(tags)
165 + l.hash, _ = model.CalcHash(l)
166 return l
167 }
src/go/plugin/go.d/agent/discovery/sd/discoverer/netlistensd/sim_test.go
-1
@@ -31,7 +31,6 @@ type discoverySim struct {
31 func (sim *discoverySim) run(t *testing.T) {
32 d, err := NewDiscoverer(Config{
33 Source: "",
34 - Tags: "netlisteners",
34 })
35 require.NoError(t, err)
36
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go
+21 -19
@@ -14,48 +14,50 @@ import (
14
15 type (
16 Config struct {
17 - Source string `yaml:"-"`
17 + Source string `yaml:"-" json:"-"`
18
19 // RescanInterval defines how often to scan the networks for devices (default: 30m)
20 - RescanInterval *confopt.Duration `yaml:"rescan_interval"`
20 + // Zero means use default. Negative means disable rescanning (run once).
21 + RescanInterval confopt.LongDuration `yaml:"rescan_interval,omitempty" json:"rescan_interval,omitempty"`
22 // Timeout defines the maximum time to wait for SNMP device responses (default: 1s)
22 - Timeout confopt.Duration `yaml:"timeout"`
23 + Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
24 // DeviceCacheTTL defines how long to trust cached discovery results before requiring a new probe (default: 12h)
24 - DeviceCacheTTL *confopt.Duration `yaml:"device_cache_ttl"`
25 + // Zero means use default. Negative means cache never expires.
26 + DeviceCacheTTL confopt.LongDuration `yaml:"device_cache_ttl,omitempty" json:"device_cache_ttl,omitempty"`
27 // ParallelScansPerNetwork defines how many IPs to scan concurrently within each subnet (default: 32)
26 - ParallelScansPerNetwork int `yaml:"parallel_scans_per_network"`
28 + ParallelScansPerNetwork int `yaml:"parallel_scans_per_network,omitempty" json:"parallel_scans_per_network,omitempty"`
29 // Credentials define the SNMP credentials used for authentication
28 - Credentials []CredentialConfig `yaml:"credentials"`
30 + Credentials []CredentialConfig `yaml:"credentials,omitempty" json:"credentials,omitempty"`
31 // Networks defines the subnets to scan and which credentials to use
30 - Networks []NetworkConfig `yaml:"networks"`
32 + Networks []NetworkConfig `yaml:"networks,omitempty" json:"networks,omitempty"`
33 }
34
35 NetworkConfig struct {
36 // Subnet is the IP range to scan, supporting various formats
37 // https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
36 - Subnet string `yaml:"subnet"`
38 + Subnet string `yaml:"subnet" json:"subnet"`
39 // Credential is the name of a credential from the Credentials list
38 - Credential string `yaml:"credential"`
40 + Credential string `yaml:"credential" json:"credential"`
41 }
42 CredentialConfig struct {
43 // Name is the identifier for this credential set, used in Network.Credential
42 - Name string `yaml:"name"`
44 + Name string `yaml:"name" json:"name"`
45 // Version must be one of: "1", "2c", or "3"
44 - Version string `yaml:"version"`
46 + Version string `yaml:"version" json:"version"`
47 // Community is the SNMP community string (used in v1 and v2c)
46 - Community string `yaml:"community"`
48 + Community string `yaml:"community,omitempty" json:"community,omitempty"`
49 // UserName is the SNMPv3 username
48 - UserName string `yaml:"username"`
50 + UserName string `yaml:"username,omitempty" json:"username,omitempty"`
51 // SecurityLevel must be one of: "noAuthNoPriv", "authNoPriv", or "authPriv" (for SNMPv3)
50 - SecurityLevel string `yaml:"security_level"`
52 + SecurityLevel string `yaml:"security_level,omitempty" json:"security_level,omitempty"`
53 // AuthProtocol must be one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" (for SNMPv3)
52 - AuthProtocol string `yaml:"auth_protocol"`
54 + AuthProtocol string `yaml:"auth_protocol,omitempty" json:"auth_protocol,omitempty"`
55 // AuthPassphrase is the authentication passphrase (for SNMPv3)
54 - AuthPassphrase string `yaml:"auth_password"`
55 - // PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" (for SNMPv3)
56 - PrivacyProtocol string `yaml:"priv_protocol"`
56 + AuthPassphrase string `yaml:"auth_password,omitempty" json:"auth_password,omitempty"`
57 + // PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192c", "aes256c" (for SNMPv3)
58 + PrivacyProtocol string `yaml:"priv_protocol,omitempty" json:"priv_protocol,omitempty"`
59 // PrivacyPassphrase is the privacy passphrase (for SNMPv3)
58 - PrivacyPassphrase string `yaml:"priv_password"`
60 + PrivacyPassphrase string `yaml:"priv_password,omitempty" json:"priv_password,omitempty"`
61 }
62 )
63
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go
+7 -3
@@ -56,17 +56,21 @@ func NewDiscoverer(cfg Config) (*Discoverer, error) {
56 status: newDiscoveryStatus(),
57 }
58
59 - if cfg.RescanInterval != nil && *cfg.RescanInterval >= 0 {
59 + if cfg.RescanInterval.Duration() > 0 {
60 d.rescanInterval = cfg.RescanInterval.Duration()
61 + } else if cfg.RescanInterval.Duration() < 0 {
62 + d.rescanInterval = 0 // negative means disable rescanning
63 }
62 - if cfg.Timeout > 0 {
64 + if cfg.Timeout.Duration() > 0 {
65 d.timeout = cfg.Timeout.Duration()
66 }
67 if cfg.ParallelScansPerNetwork > 0 {
68 d.parallelScansPerNetwork = cfg.ParallelScansPerNetwork
69 }
68 - if cfg.DeviceCacheTTL != nil && *cfg.DeviceCacheTTL >= 0 {
70 + if cfg.DeviceCacheTTL.Duration() > 0 {
71 d.deviceCacheTTL = cfg.DeviceCacheTTL.Duration()
72 + } else if cfg.DeviceCacheTTL.Duration() < 0 {
73 + d.deviceCacheTTL = 0 // negative means cache never expires
74 }
75
76 return d, nil
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go
+1 -3
@@ -6,8 +6,6 @@ import (
6 "fmt"
7 "sync"
8
9 - "github.com/gohugoio/hashstructure"
10 -
9 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
11 )
@@ -45,7 +43,7 @@ func newTarget(ip string, cred CredentialConfig, si snmputils.SysInfo) *target {
43 SysInfo: si,
44 }
45
48 - tg.hash, _ = hashstructure.Hash(tg, nil)
46 + tg.hash, _ = model.CalcHash(tg)
47
48 return tg
49 }
src/go/plugin/go.d/agent/discovery/sd/dyncfg.go new
+698
@@ -0,0 +1,698 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "context"
7 + "fmt"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/executable"
11 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
15 +)
16 +
17 +// Discoverer types supported by SD dyncfg
18 +const (
19 + DiscovererNetListeners = "net_listeners"
20 + DiscovererDocker = "docker"
21 + DiscovererK8s = "k8s"
22 + DiscovererSNMP = "snmp"
23 +)
24 +
25 +var discovererTypes = []string{
26 + DiscovererNetListeners,
27 + DiscovererDocker,
28 + DiscovererK8s,
29 + DiscovererSNMP,
30 +}
31 +
32 +const (
33 + dyncfgSDPrefixf = "%s:sd:"
34 + dyncfgSDPath = "/collectors/%s/ServiceDiscovery"
35 +)
36 +
37 +func (d *ServiceDiscovery) dyncfgSDPrefixValue() string {
38 + return fmt.Sprintf(dyncfgSDPrefixf, executable.Name)
39 +}
40 +
41 +func (d *ServiceDiscovery) dyncfgTemplateID(discovererType string) string {
42 + return fmt.Sprintf("%s%s", d.dyncfgSDPrefixValue(), discovererType)
43 +}
44 +
45 +func (d *ServiceDiscovery) dyncfgJobID(discovererType, name string) string {
46 + return fmt.Sprintf("%s%s:%s", d.dyncfgSDPrefixValue(), discovererType, name)
47 +}
48 +
49 +func dyncfgSDTemplateCmds() string {
50 + return dyncfg.JoinCommands(
51 + dyncfg.CommandAdd,
52 + dyncfg.CommandSchema,
53 + dyncfg.CommandTest,
54 + dyncfg.CommandUserconfig,
55 + )
56 +}
57 +
58 +func dyncfgSDJobCmds(isDyncfgJob bool) string {
59 + cmds := []dyncfg.Command{
60 + dyncfg.CommandSchema,
61 + dyncfg.CommandGet,
62 + dyncfg.CommandTest,
63 + dyncfg.CommandEnable,
64 + dyncfg.CommandDisable,
65 + dyncfg.CommandUpdate,
66 + dyncfg.CommandUserconfig,
67 + }
68 + if isDyncfgJob {
69 + cmds = append(cmds, dyncfg.CommandRemove)
70 + }
71 + return dyncfg.JoinCommands(cmds...)
72 +}
73 +
74 +func (d *ServiceDiscovery) dyncfgSDTemplateCreate(discovererType string) {
75 + d.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
76 + ID: d.dyncfgTemplateID(discovererType),
77 + Status: dyncfg.StatusAccepted.String(),
78 + ConfigType: dyncfg.ConfigTypeTemplate.String(),
79 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
80 + SourceType: "internal",
81 + Source: "internal",
82 + SupportedCommands: dyncfgSDTemplateCmds(),
83 + })
84 +}
85 +
86 +func (d *ServiceDiscovery) dyncfgSDJobCreate(discovererType, name, sourceType, source string, status dyncfg.Status) {
87 + isDyncfg := sourceType == "dyncfg"
88 + cmds := dyncfgSDJobCmds(isDyncfg)
89 + d.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
90 + ID: d.dyncfgJobID(discovererType, name),
91 + Status: status.String(),
92 + ConfigType: dyncfg.ConfigTypeJob.String(),
93 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
94 + SourceType: sourceType,
95 + Source: source,
96 + SupportedCommands: cmds,
97 + })
98 +}
99 +
100 +func (d *ServiceDiscovery) dyncfgSDJobRemove(discovererType, name string) {
101 + d.dyncfgApi.ConfigDelete(d.dyncfgJobID(discovererType, name))
102 +}
103 +
104 +func (d *ServiceDiscovery) dyncfgSDJobStatus(discovererType, name string, status dyncfg.Status) {
105 + d.dyncfgApi.ConfigStatus(d.dyncfgJobID(discovererType, name), status)
106 +}
107 +
108 +// dyncfgConfigHandler wraps dyncfgConfig to convert functions.Function to dyncfg.Function.
109 +// This is needed because functions.Registry expects func(functions.Function).
110 +func (d *ServiceDiscovery) dyncfgConfigHandler(fn functions.Function) {
111 + d.dyncfgConfig(dyncfg.NewFunction(fn))
112 +}
113 +
114 +// dyncfgConfig is the handler for dyncfg config commands.
115 +// Read-only commands (schema, get, userconfig) are executed directly.
116 +// State-changing commands are queued for serial execution.
117 +func (d *ServiceDiscovery) dyncfgConfig(fn dyncfg.Function) {
118 + if err := fn.ValidateArgs(2); err != nil {
119 + d.Warningf("dyncfg: %v", err)
120 + d.dyncfgApi.SendCodef(fn, 400, "%v", err)
121 + return
122 + }
123 +
124 + // Read-only commands can be executed directly
125 + switch fn.Command() {
126 + case dyncfg.CommandSchema:
127 + d.dyncfgCmdSchema(fn)
128 + return
129 + case dyncfg.CommandGet:
130 + d.dyncfgCmdGet(fn)
131 + return
132 + case dyncfg.CommandUserconfig:
133 + d.dyncfgCmdUserconfig(fn)
134 + return
135 + case dyncfg.CommandTest:
136 + // Test command validates config without creating a job
137 + d.dyncfgCmdTest(fn)
138 + return
139 + }
140 +
141 + // State-changing commands are queued for serial execution
142 + select {
143 + case <-d.ctx.Done():
144 + d.dyncfgApi.SendCodef(fn, 503, "Service discovery is shutting down.")
145 + case d.dyncfgCh <- fn:
146 + }
147 +}
148 +
149 +// dyncfgSeqExec executes state-changing dyncfg commands serially.
150 +func (d *ServiceDiscovery) dyncfgSeqExec(fn dyncfg.Function) {
151 + switch fn.Command() {
152 + case dyncfg.CommandAdd:
153 + d.dyncfgCmdAdd(fn)
154 + case dyncfg.CommandUpdate:
155 + d.dyncfgCmdUpdate(fn)
156 + case dyncfg.CommandEnable:
157 + d.dyncfgCmdEnable(fn)
158 + case dyncfg.CommandDisable:
159 + d.dyncfgCmdDisable(fn)
160 + case dyncfg.CommandRemove:
161 + d.dyncfgCmdRemove(fn)
162 + default:
163 + d.Warningf("dyncfg: command '%s' not implemented", fn.Command())
164 + d.dyncfgApi.SendCodef(fn, 501, "Command '%s' is not implemented.", fn.Command())
165 + }
166 +}
167 +
168 +// dyncfgCmdSchema handles the schema command for templates and jobs
169 +func (d *ServiceDiscovery) dyncfgCmdSchema(fn dyncfg.Function) {
170 + id := fn.ID()
171 + dt, _, _ := d.extractDiscovererAndName(id)
172 +
173 + if dt == "" {
174 + d.Warningf("dyncfg: schema: invalid ID format '%s'", id)
175 + d.dyncfgApi.SendCodef(fn, 400, "Invalid ID format: %s", id)
176 + return
177 + }
178 +
179 + if !isValidDiscovererType(dt) {
180 + d.Warningf("dyncfg: schema: unknown discoverer type '%s'", dt)
181 + d.dyncfgApi.SendCodef(fn, 404, "Unknown discoverer type: %s", dt)
182 + return
183 + }
184 +
185 + schema := getDiscovererSchemaByType(dt)
186 + d.dyncfgApi.SendJSON(fn, schema)
187 +}
188 +
189 +// dyncfgCmdGet handles the get command for jobs
190 +func (d *ServiceDiscovery) dyncfgCmdGet(fn dyncfg.Function) {
191 + id := fn.ID()
192 + dt, name, isJob := d.extractDiscovererAndName(id)
193 +
194 + if !isJob || name == "" {
195 + d.Warningf("dyncfg: get: invalid job ID format '%s'", id)
196 + d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
197 + return
198 + }
199 +
200 + cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
201 + if !ok {
202 + d.Warningf("dyncfg: get: config '%s:%s' not found", dt, name)
203 + d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
204 + return
205 + }
206 +
207 + // Convert stored config to JSON via typed struct for consistent field ordering
208 + bs, err := configToJSON(cfg.DataJSON())
209 + if err != nil {
210 + d.Warningf("dyncfg: get: failed to convert config '%s:%s' to JSON: %v", dt, name, err)
211 + d.dyncfgApi.SendCodef(fn, 500, "Failed to convert config to JSON: %v", err)
212 + return
213 + }
214 +
215 + d.dyncfgApi.SendJSON(fn, string(bs))
216 +}
217 +
218 +// dyncfgCmdAdd handles the add command for templates (creates a new job)
219 +func (d *ServiceDiscovery) dyncfgCmdAdd(fn dyncfg.Function) {
220 + if err := fn.ValidateArgs(3); err != nil {
221 + d.Warningf("dyncfg: add: %v", err)
222 + d.dyncfgApi.SendCodef(fn, 400, "%v", err)
223 + return
224 + }
225 +
226 + id := fn.ID()
227 + name := fn.JobName()
228 +
229 + dt, _, _ := d.extractDiscovererAndName(id)
230 + if dt == "" || !isValidDiscovererType(dt) {
231 + d.Warningf("dyncfg: add: invalid discoverer type in ID '%s'", id)
232 + d.dyncfgApi.SendCodef(fn, 400, "Invalid discoverer type in ID: %s", id)
233 + return
234 + }
235 +
236 + if name == "" {
237 + d.Warningf("dyncfg: add: missing job name")
238 + d.dyncfgApi.SendCodef(fn, 400, "Missing job name.")
239 + return
240 + }
241 +
242 + if err := fn.ValidateHasPayload(); err != nil {
243 + d.Warningf("dyncfg: add: %v for '%s:%s'", err, dt, name)
244 + d.dyncfgApi.SendCodef(fn, 400, "%v", err)
245 + return
246 + }
247 +
248 + if err := validateJobName(name); err != nil {
249 + d.Warningf("dyncfg: add: unacceptable job name '%s': %v", name, err)
250 + d.dyncfgApi.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", name, err)
251 + return
252 + }
253 +
254 + // Validate config by parsing it
255 + if _, err := parseDyncfgPayload(fn.Payload(), dt, d.configDefaults); err != nil {
256 + d.Warningf("dyncfg: add: invalid config for '%s:%s': %v", dt, name, err)
257 + d.dyncfgApi.SendCodef(fn, 400, "Invalid config: %v", err)
258 + return
259 + }
260 +
261 + // Create sdConfig from JSON payload
262 + pkey := pipelineKey(dt, name)
263 + cfg, err := newSDConfigFromJSON(fn.Payload(), name, fn.Source(), confgroup.TypeDyncfg, dt, pkey)
264 + if err != nil {
265 + d.Warningf("dyncfg: add: failed to create config '%s:%s': %v", dt, name, err)
266 + d.dyncfgApi.SendCodef(fn, 400, "Failed to create config: %v", err)
267 + return
268 + }
269 +
270 + d.Infof("dyncfg: add: %s:%s by user '%s'", dt, name, fn.User())
271 +
272 + // If config with same key already exists, replace it (matching jobmgr pattern)
273 + if ecfg, ok := d.exposedConfigs.lookup(cfg); ok {
274 + // Only remove from seenConfigs if it's a dyncfg config
275 + // (file-based configs are removed via other codepath when file is deleted)
276 + if scfg, ok := d.seenConfigs.lookup(ecfg); ok && scfg.SourceType() == confgroup.TypeDyncfg {
277 + d.seenConfigs.remove(ecfg)
278 + }
279 + d.exposedConfigs.remove(ecfg)
280 + d.mgr.Stop(ecfg.PipelineKey())
281 + }
282 +
283 + // Add to both caches
284 + d.seenConfigs.add(cfg)
285 + d.exposedConfigs.add(cfg)
286 +
287 + d.dyncfgApi.SendCodef(fn, 202, "")
288 + d.dyncfgSDJobCreate(dt, name, cfg.SourceType(), cfg.Source(), cfg.Status())
289 +}
290 +
291 +// dyncfgCmdTest handles the test command for templates and jobs (validates config without applying it)
292 +func (d *ServiceDiscovery) dyncfgCmdTest(fn dyncfg.Function) {
293 + id := fn.ID()
294 +
295 + dt, name, isJob := d.extractDiscovererAndName(id)
296 + if dt == "" || !isValidDiscovererType(dt) {
297 + d.Warningf("dyncfg: test: invalid discoverer type in ID '%s'", id)
298 + d.dyncfgApi.SendCodef(fn, 400, "Invalid discoverer type in ID: %s", id)
299 + return
300 + }
301 +
302 + if err := fn.ValidateHasPayload(); err != nil {
303 + d.Warningf("dyncfg: test: %v for '%s'", err, dt)
304 + d.dyncfgApi.SendCodef(fn, 400, "%v", err)
305 + return
306 + }
307 +
308 + // Parse and validate the config without storing it
309 + _, err := parseDyncfgPayload(fn.Payload(), dt, d.configDefaults)
310 + if err != nil {
311 + d.Warningf("dyncfg: test: failed to parse config for '%s': %v", dt, err)
312 + d.dyncfgApi.SendCodef(fn, 400, "Failed to parse config: %v", err)
313 + return
314 + }
315 +
316 + if isJob {
317 + d.Infof("dyncfg: test: config for '%s:%s' is valid", dt, name)
318 + } else {
319 + d.Infof("dyncfg: test: config for '%s' is valid", dt)
320 + }
321 + d.dyncfgApi.SendCodef(fn, 200, "")
322 +}
323 +
324 +// dyncfgCmdUpdate handles the update command for jobs
325 +func (d *ServiceDiscovery) dyncfgCmdUpdate(fn dyncfg.Function) {
326 + id := fn.ID()
327 + dt, name, isJob := d.extractDiscovererAndName(id)
328 +
329 + if !isJob || name == "" {
330 + d.Warningf("dyncfg: update: invalid job ID format '%s'", id)
331 + d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
332 + return
333 + }
334 +
335 + ecfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
336 + if !ok {
337 + d.Warningf("dyncfg: update: config '%s:%s' not found", dt, name)
338 + d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
339 + return
340 + }
341 +
342 + if err := fn.ValidateHasPayload(); err != nil {
343 + d.Warningf("dyncfg: update: %v for '%s:%s'", err, dt, name)
344 + d.dyncfgApi.SendCodef(fn, 400, "%v", err)
345 + return
346 + }
347 +
348 + // Parse the new config to validate it
349 + pipelineCfg, err := parseDyncfgPayload(fn.Payload(), dt, d.configDefaults)
350 + if err != nil {
351 + d.Warningf("dyncfg: update: failed to parse config '%s:%s': %v", dt, name, err)
352 + d.dyncfgApi.SendCodef(fn, 400, "Failed to parse config: %v", err)
353 + return
354 + }
355 +
356 + // Updating a non-dyncfg config converts it to dyncfg (creates an override).
357 + // This ensures changes persist and take priority over file configs.
358 + isConversion := ecfg.SourceType() != confgroup.TypeDyncfg
359 + var newSource, newSourceType, newPipelineKey string
360 +
361 + if isConversion {
362 + newSource = fn.Source()
363 + newSourceType = confgroup.TypeDyncfg
364 + newPipelineKey = pipelineKey(dt, name)
365 + pipelineCfg.Source = fmt.Sprintf("dyncfg=%s", newSource)
366 + } else {
367 + newSource = fn.Source()
368 + newSourceType = confgroup.TypeDyncfg
369 + newPipelineKey = ecfg.PipelineKey()
370 + pipelineCfg.Source = fmt.Sprintf("dyncfg=%s", newSource)
371 + }
372 +
373 + // Create updated sdConfig
374 + newCfg, err := newSDConfigFromJSON(fn.Payload(), name, newSource, newSourceType, dt, newPipelineKey)
375 + if err != nil {
376 + d.Warningf("dyncfg: update: failed to create config '%s:%s': %v", dt, name, err)
377 + d.dyncfgApi.SendCodef(fn, 400, "Failed to create config: %v", err)
378 + return
379 + }
380 +
381 + // If running, not a conversion, and config unchanged, return early (optimization)
382 + // Skip this optimization for conversions (file->dyncfg) since we need to change source type
383 + if !isConversion && ecfg.Status() == dyncfg.StatusRunning && ecfg.Hash() == newCfg.Hash() {
384 + d.dyncfgApi.SendCodef(fn, 200, "")
385 + d.dyncfgSDJobStatus(dt, name, ecfg.Status())
386 + return
387 + }
388 +
389 + // Update not allowed in Accepted state (matching jobmgr pattern)
390 + if ecfg.Status() == dyncfg.StatusAccepted {
391 + d.Warningf("dyncfg: update: config '%s:%s': updating not allowed in %s state", dt, name, ecfg.Status())
392 + d.dyncfgApi.SendCodef(fn, 403, "Updating is not allowed in '%s' state.", ecfg.Status())
393 + d.dyncfgSDJobStatus(dt, name, ecfg.Status())
394 + return
395 + }
396 +
397 + d.Infof("dyncfg: update: %s:%s by user '%s'", dt, name, fn.User())
398 +
399 + // Update caches
400 + // When old was dyncfg: remove old from seenConfigs (cleanup stale entry)
401 + // When old was file: keep in seenConfigs (for re-exposure if dyncfg removed later)
402 + if !isConversion {
403 + d.seenConfigs.remove(ecfg)
404 + }
405 + d.seenConfigs.add(newCfg)
406 + d.exposedConfigs.add(newCfg)
407 +
408 + // For conversion: remove old dyncfg job, will create new one below
409 + if isConversion {
410 + d.dyncfgSDJobRemove(dt, name)
411 + }
412 +
413 + // If old status was Accepted or Disabled, preserve it (don't auto-start)
414 + if ecfg.Status() == dyncfg.StatusAccepted || ecfg.Status() == dyncfg.StatusDisabled {
415 + newCfg.SetStatus(ecfg.Status())
416 + d.exposedConfigs.updateStatus(newCfg, ecfg.Status())
417 + if isConversion {
418 + d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, ecfg.Status())
419 + }
420 + d.dyncfgApi.SendCodef(fn, 200, "")
421 + d.dyncfgSDJobStatus(dt, name, ecfg.Status())
422 + return
423 + }
424 +
425 + // Restart/start pipeline with new config
426 + if isConversion {
427 + // Conversion: pipeline keys differ, need Stop + Start
428 + d.mgr.Stop(ecfg.PipelineKey())
429 + err = d.mgr.Start(d.ctx, newPipelineKey, pipelineCfg)
430 + } else {
431 + // Non-conversion: same pipeline key, use Restart for graceful transition
432 + // Restart validates new config before stopping old, uses grace period
433 + err = d.mgr.Restart(d.ctx, newPipelineKey, pipelineCfg)
434 + }
435 +
436 + if err != nil {
437 + d.Errorf("dyncfg: update: failed to start pipeline '%s:%s': %v", dt, name, err)
438 + newCfg.SetStatus(dyncfg.StatusFailed)
439 + d.exposedConfigs.updateStatus(newCfg, dyncfg.StatusFailed)
440 + if isConversion {
441 + d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, dyncfg.StatusFailed)
442 + }
443 + d.dyncfgApi.SendCodef(fn, 200, "")
444 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
445 + return
446 + }
447 +
448 + newCfg.SetStatus(dyncfg.StatusRunning)
449 + d.exposedConfigs.updateStatus(newCfg, dyncfg.StatusRunning)
450 + if isConversion {
451 + d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, dyncfg.StatusRunning)
452 + }
453 + d.dyncfgApi.SendCodef(fn, 200, "")
454 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusRunning)
455 +}
456 +
457 +// dyncfgCmdEnable handles the enable command for jobs
458 +func (d *ServiceDiscovery) dyncfgCmdEnable(fn dyncfg.Function) {
459 + id := fn.ID()
460 + dt, name, isJob := d.extractDiscovererAndName(id)
461 +
462 + if !isJob || name == "" {
463 + d.Warningf("dyncfg: enable: invalid job ID format '%s'", id)
464 + d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
465 + return
466 + }
467 +
468 + cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
469 + if !ok {
470 + d.Warningf("dyncfg: enable: config '%s:%s' not found", dt, name)
471 + d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
472 + return
473 + }
474 +
475 + pkey := cfg.PipelineKey()
476 +
477 + // Clear wait flag if this is the config we're waiting for
478 + if pkey == d.waitCfgOnOff {
479 + d.waitCfgOnOff = ""
480 + }
481 +
482 + switch cfg.Status() {
483 + case dyncfg.StatusAccepted, dyncfg.StatusDisabled, dyncfg.StatusFailed:
484 + // proceed with enable
485 + case dyncfg.StatusRunning:
486 + // already running, return success (idempotent)
487 + d.dyncfgApi.SendCodef(fn, 200, "")
488 + d.dyncfgSDJobStatus(dt, name, cfg.Status())
489 + return
490 + default:
491 + d.Warningf("dyncfg: enable: config '%s:%s': enabling not allowed in %s state", dt, name, cfg.Status())
492 + d.dyncfgApi.SendCodef(fn, 405, "Enabling is not allowed in '%s' state.", cfg.Status())
493 + d.dyncfgSDJobStatus(dt, name, cfg.Status())
494 + return
495 + }
496 +
497 + // Convert sdConfig to pipeline.Config
498 + pipelineCfg, err := cfg.ToPipelineConfig(d.configDefaults)
499 + if err != nil {
500 + d.Warningf("dyncfg: enable: failed to parse config '%s:%s': %v", dt, name, err)
501 + d.exposedConfigs.updateStatus(cfg, dyncfg.StatusFailed)
502 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
503 + d.dyncfgApi.SendCodef(fn, 422, "Failed to parse config: %v", err)
504 + return
505 + }
506 +
507 + if cfg.Status() == dyncfg.StatusDisabled {
508 + d.Infof("dyncfg: enable: %s:%s by user '%s'", dt, name, fn.User())
509 + }
510 +
511 + if err := d.mgr.Start(d.ctx, pkey, pipelineCfg); err != nil {
512 + d.Errorf("dyncfg: enable: failed to start pipeline '%s:%s': %v", dt, name, err)
513 + d.exposedConfigs.updateStatus(cfg, dyncfg.StatusFailed)
514 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
515 + d.dyncfgApi.SendCodef(fn, 422, "Failed to start pipeline: %v", err)
516 + return
517 + }
518 +
519 + d.exposedConfigs.updateStatus(cfg, dyncfg.StatusRunning)
520 + d.dyncfgApi.SendCodef(fn, 200, "")
521 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusRunning)
522 +}
523 +
524 +// dyncfgCmdDisable handles the disable command for jobs
525 +func (d *ServiceDiscovery) dyncfgCmdDisable(fn dyncfg.Function) {
526 + id := fn.ID()
527 + dt, name, isJob := d.extractDiscovererAndName(id)
528 +
529 + if !isJob || name == "" {
530 + d.Warningf("dyncfg: disable: invalid job ID format '%s'", id)
531 + d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
532 + return
533 + }
534 +
535 + cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
536 + if !ok {
537 + d.Warningf("dyncfg: disable: config '%s:%s' not found", dt, name)
538 + d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
539 + return
540 + }
541 +
542 + pkey := cfg.PipelineKey()
543 +
544 + // Clear wait flag if this is the config we're waiting for
545 + if pkey == d.waitCfgOnOff {
546 + d.waitCfgOnOff = ""
547 + }
548 +
549 + switch cfg.Status() {
550 + case dyncfg.StatusDisabled:
551 + // already disabled, return success (idempotent)
552 + d.dyncfgApi.SendCodef(fn, 200, "")
553 + d.dyncfgSDJobStatus(dt, name, cfg.Status())
554 + return
555 + case dyncfg.StatusRunning:
556 + d.mgr.Stop(pkey)
557 + default:
558 + // Accepted, Failed - just proceed to set Disabled
559 + }
560 +
561 + d.Infof("dyncfg: disable: %s:%s by user '%s'", dt, name, fn.User())
562 +
563 + d.exposedConfigs.updateStatus(cfg, dyncfg.StatusDisabled)
564 + d.dyncfgApi.SendCodef(fn, 200, "")
565 + d.dyncfgSDJobStatus(dt, name, dyncfg.StatusDisabled)
566 +}
567 +
568 +// dyncfgCmdRemove handles the remove command for dyncfg jobs
569 +func (d *ServiceDiscovery) dyncfgCmdRemove(fn dyncfg.Function) {
570 + id := fn.ID()
571 + dt, name, isJob := d.extractDiscovererAndName(id)
572 +
573 + if !isJob || name == "" {
574 + d.Warningf("dyncfg: remove: invalid job ID format '%s'", id)
575 + d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
576 + return
577 + }
578 +
579 + cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
580 + if !ok {
581 + d.Warningf("dyncfg: remove: config '%s:%s' not found", dt, name)
582 + d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
583 + return
584 + }
585 +
586 + if cfg.SourceType() != confgroup.TypeDyncfg {
587 + d.Warningf("dyncfg: remove: cannot remove non-dyncfg config '%s:%s' (source: %s)", dt, name, cfg.SourceType())
588 + d.dyncfgApi.SendCodef(fn, 405, "Cannot remove non-dyncfg configs. Source type: %s", cfg.SourceType())
589 + return
590 + }
591 +
592 + d.Infof("dyncfg: remove: removing config '%s:%s'", dt, name)
593 +
594 + d.mgr.Stop(cfg.PipelineKey())
595 +
596 + // Remove from both caches
597 + d.seenConfigs.remove(cfg)
598 + d.exposedConfigs.remove(cfg)
599 +
600 + // TODO: After removing dyncfg config, check if a lower-priority config (user/stock file)
601 + // exists in seenConfigs with the same Key(). If so, promote it to exposedConfigs and
602 + // recreate the dyncfg job. This would allow file configs to "take over" when dyncfg
603 + // override is removed.
604 +
605 + // Response before delete (matching jobmgr pattern)
606 + d.dyncfgApi.SendCodef(fn, 200, "")
607 + d.dyncfgSDJobRemove(dt, name)
608 +}
609 +
610 +// dyncfgCmdUserconfig handles the userconfig command for templates and jobs
611 +// Returns YAML representation of the config for user-friendly file format
612 +func (d *ServiceDiscovery) dyncfgCmdUserconfig(fn dyncfg.Function) {
613 + id := fn.ID()
614 + dt, _, _ := d.extractDiscovererAndName(id)
615 +
616 + if !isValidDiscovererType(dt) {
617 + d.Warningf("dyncfg: userconfig: invalid discoverer type in ID '%s'", id)
618 + d.dyncfgApi.SendCodef(fn, 400, "Invalid discoverer type in ID: %s", id)
619 + return
620 + }
621 +
622 + if !fn.HasPayload() {
623 + d.Warningf("dyncfg: userconfig: missing payload for '%s'", id)
624 + d.dyncfgApi.SendCodef(fn, 400, "Missing configuration payload.")
625 + return
626 + }
627 +
628 + jobName := fn.JobName() // May be empty - userConfigFromPayload will use name from payload or default
629 +
630 + bs, err := userConfigFromPayload(fn.Payload(), dt, jobName)
631 + if err != nil {
632 + d.Warningf("dyncfg: userconfig: failed to create config for '%s': %v", id, err)
633 + d.dyncfgApi.SendCodef(fn, 400, "Failed to create config: %v", err)
634 + return
635 + }
636 +
637 + d.dyncfgApi.SendYAML(fn, string(bs))
638 +}
639 +
640 +// extractDiscovererAndName parses a dyncfg ID into discoverer type and name.
641 +// ID format: {prefix}{discovererType} (template) or {prefix}{discovererType}:{name} (job)
642 +// Returns discovererType, name, isJob
643 +func (d *ServiceDiscovery) extractDiscovererAndName(id string) (discovererType, name string, isJob bool) {
644 + prefix := d.dyncfgSDPrefixValue()
645 + if !strings.HasPrefix(id, prefix) {
646 + return "", "", false
647 + }
648 +
649 + rest := strings.TrimPrefix(id, prefix)
650 + if rest == "" {
651 + return "", "", false
652 + }
653 +
654 + parts := strings.SplitN(rest, ":", 2)
655 + discovererType = parts[0]
656 +
657 + if len(parts) == 2 {
658 + name = parts[1]
659 + isJob = true
660 + }
661 +
662 + return discovererType, name, isJob
663 +}
664 +
665 +func isValidDiscovererType(dt string) bool {
666 + for _, valid := range discovererTypes {
667 + if dt == valid {
668 + return true
669 + }
670 + }
671 + return false
672 +}
673 +
674 +// registerDyncfgTemplates registers dyncfg templates for each discoverer type
675 +func (d *ServiceDiscovery) registerDyncfgTemplates(ctx context.Context) {
676 + if d.fnReg == nil || disableDyncfg {
677 + return
678 + }
679 +
680 + // Register prefix handler for config commands
681 + // Wrap to convert functions.Function to dyncfg.Function
682 + d.fnReg.RegisterPrefix("config", d.dyncfgSDPrefixValue(), d.dyncfgConfigHandler)
683 +
684 + // Register templates for each discoverer type
685 + for _, dt := range discovererTypes {
686 + d.dyncfgSDTemplateCreate(dt)
687 + d.Infof("registered dyncfg template for discoverer type '%s'", dt)
688 + }
689 +}
690 +
691 +// unregisterDyncfgTemplates unregisters dyncfg templates
692 +func (d *ServiceDiscovery) unregisterDyncfgTemplates() {
693 + if d.fnReg == nil || disableDyncfg {
694 + return
695 + }
696 +
697 + d.fnReg.UnregisterPrefix("config", d.dyncfgSDPrefixValue())
698 +}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_cache.go new
+342
@@ -0,0 +1,342 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "encoding/json"
7 + "fmt"
8 + "strings"
9 + "sync"
10 +
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
14 +
15 + "github.com/gohugoio/hashstructure"
16 + "gopkg.in/yaml.v2"
17 +)
18 +
19 +// Internal metadata keys (excluded from JSON output, same pattern as confgroup.Config)
20 +const (
21 + ikeySource = "__source__"
22 + ikeySourceType = "__source_type__"
23 + ikeyDiscovererType = "__discoverer_type__"
24 + ikeyPipelineKey = "__pipeline_key__"
25 + ikeyStatus = "__status__"
26 +)
27 +
28 +// sdConfig represents a service discovery pipeline configuration.
29 +// Uses map[string]any with __ metadata fields, same pattern as confgroup.Config.
30 +// The actual config data is stored alongside metadata and parsed to pipeline.Config only when needed.
31 +type sdConfig map[string]any
32 +
33 +func (c sdConfig) Source() string { v, _ := c[ikeySource].(string); return v }
34 +func (c sdConfig) SourceType() string { v, _ := c[ikeySourceType].(string); return v }
35 +func (c sdConfig) DiscovererType() string { v, _ := c[ikeyDiscovererType].(string); return v }
36 +func (c sdConfig) PipelineKey() string { v, _ := c[ikeyPipelineKey].(string); return v }
37 +func (c sdConfig) Name() string { v, _ := c["name"].(string); return v }
38 +
39 +func (c sdConfig) Status() dyncfg.Status {
40 + v, _ := c[ikeyStatus].(dyncfg.Status)
41 + return v
42 +}
43 +
44 +// HashIncludeMap implements hashstructure.HashIncludeMap to exclude __ metadata keys from hashing.
45 +// Same pattern as confgroup.Config.
46 +func (c sdConfig) HashIncludeMap(_ string, k, _ any) (bool, error) {
47 + s := k.(string)
48 + return !strings.HasPrefix(s, "__") && !strings.HasSuffix(s, "__"), nil
49 +}
50 +
51 +// Hash returns a hash of the config data (excluding __ metadata keys).
52 +// Used for comparing configs to detect changes.
53 +func (c sdConfig) Hash() uint64 {
54 + hash, _ := hashstructure.Hash(c, nil)
55 + return hash
56 +}
57 +
58 +func (c sdConfig) SetSource(v string) sdConfig { c[ikeySource] = v; return c }
59 +func (c sdConfig) SetSourceType(v string) sdConfig { c[ikeySourceType] = v; return c }
60 +func (c sdConfig) SetDiscovererType(v string) sdConfig { c[ikeyDiscovererType] = v; return c }
61 +func (c sdConfig) SetPipelineKey(v string) sdConfig { c[ikeyPipelineKey] = v; return c }
62 +func (c sdConfig) SetStatus(v dyncfg.Status) sdConfig { c[ikeyStatus] = v; return c }
63 +
64 +// Key returns the logical key for exposedConfigs: "discovererType:name"
65 +func (c sdConfig) Key() string {
66 + return c.DiscovererType() + ":" + c.Name()
67 +}
68 +
69 +// UID returns the unique key for seenConfigs: "source:discovererType:name"
70 +func (c sdConfig) UID() string {
71 + return c.Source() + ":" + c.Key()
72 +}
73 +
74 +// SourceTypePriority returns priority based on source type.
75 +// Higher value = higher priority. Matches confgroup.Config pattern.
76 +func (c sdConfig) SourceTypePriority() int {
77 + switch c.SourceType() {
78 + case confgroup.TypeDyncfg:
79 + return 16
80 + case confgroup.TypeUser:
81 + return 8
82 + case confgroup.TypeStock:
83 + return 2
84 + default:
85 + return 0
86 + }
87 +}
88 +
89 +// Clone returns a deep copy of the config using JSON marshal/unmarshal.
90 +func (c sdConfig) Clone() sdConfig {
91 + data, err := json.Marshal(c)
92 + if err != nil {
93 + // Fallback to shallow copy if marshal fails (shouldn't happen)
94 + clone := make(sdConfig, len(c))
95 + for k, v := range c {
96 + clone[k] = v
97 + }
98 + return clone
99 + }
100 + var clone sdConfig
101 + if err := json.Unmarshal(data, &clone); err != nil {
102 + // Fallback to shallow copy
103 + clone = make(sdConfig, len(c))
104 + for k, v := range c {
105 + clone[k] = v
106 + }
107 + return clone
108 + }
109 + // Restore metadata from original (JSON may lose type info for type aliases)
110 + clone.SetSource(c.Source())
111 + clone.SetSourceType(c.SourceType())
112 + clone.SetDiscovererType(c.DiscovererType())
113 + clone.SetPipelineKey(c.PipelineKey())
114 + clone.SetStatus(c.Status())
115 + return clone
116 +}
117 +
118 +// ToPipelineConfig converts sdConfig to pipeline.Config for actually running the pipeline.
119 +// This parses the config data (excluding __ fields) into the typed struct.
120 +func (c sdConfig) ToPipelineConfig(configDefaults confgroup.Registry) (pipeline.Config, error) {
121 + // Marshal without __ fields, then unmarshal to pipeline.Config
122 + data := c.DataJSON()
123 +
124 + var cfg pipeline.Config
125 + if err := json.Unmarshal(data, &cfg); err != nil {
126 + return pipeline.Config{}, fmt.Errorf("unmarshal pipeline config: %w", err)
127 + }
128 +
129 + cfg.ConfigDefaults = configDefaults
130 +
131 + // Set source based on source type
132 + switch c.SourceType() {
133 + case confgroup.TypeDyncfg:
134 + cfg.Source = fmt.Sprintf("dyncfg=%s", c.Source())
135 + default:
136 + cfg.Source = fmt.Sprintf("file=%s", c.Source())
137 + }
138 +
139 + return cfg, nil
140 +}
141 +
142 +// DataJSON returns JSON representation of config data (excluding __ metadata fields).
143 +// Used for dyncfg get command and for converting to pipeline.Config.
144 +func (c sdConfig) DataJSON() []byte {
145 + data := make(map[string]any, len(c))
146 + for k, v := range c {
147 + if !strings.HasPrefix(k, "__") {
148 + data[k] = v
149 + }
150 + }
151 + b, _ := json.Marshal(data)
152 + return b
153 +}
154 +
155 +// cleanName sanitizes a name for use in dyncfg IDs.
156 +// Replaces spaces and colons with underscores to avoid parsing issues.
157 +func cleanName(name string) string {
158 + name = strings.ReplaceAll(name, " ", "_")
159 + name = strings.ReplaceAll(name, ":", "_")
160 + return name
161 +}
162 +
163 +// newSDConfigFromYAML creates an sdConfig from YAML bytes.
164 +// Used when loading file configs. Cleans the name for dyncfg compatibility.
165 +func newSDConfigFromYAML(data []byte, source, sourceType, pipelineKey string) (sdConfig, error) {
166 + // First unmarshal to pipeline.Config to get discoverer type and apply YAML processing
167 + var cfg pipeline.Config
168 + if err := yaml.Unmarshal(data, &cfg); err != nil {
169 + return nil, fmt.Errorf("unmarshal yaml: %w", err)
170 + }
171 +
172 + // Now marshal to JSON and unmarshal to map for sdConfig
173 + jsonData, err := json.Marshal(cfg)
174 + if err != nil {
175 + return nil, fmt.Errorf("marshal to json: %w", err)
176 + }
177 +
178 + var m sdConfig
179 + if err := json.Unmarshal(jsonData, &m); err != nil {
180 + return nil, fmt.Errorf("unmarshal to map: %w", err)
181 + }
182 +
183 + // Clean the name for dyncfg compatibility
184 + if name := m.Name(); name != "" {
185 + m["name"] = cleanName(name)
186 + }
187 +
188 + // Add metadata
189 + m.SetSource(source)
190 + m.SetSourceType(sourceType)
191 + m.SetDiscovererType(cfg.Discoverer.Type())
192 + m.SetPipelineKey(pipelineKey)
193 + m.SetStatus(dyncfg.StatusAccepted)
194 +
195 + return m, nil
196 +}
197 +
198 +// newSDConfigFromJSON creates an sdConfig from JSON payload.
199 +// Used when receiving dyncfg add/update commands.
200 +// The name parameter is forced onto the config (from dyncfg job ID), matching jobmgr pattern.
201 +func newSDConfigFromJSON(data []byte, name, source, sourceType, discovererType, pipelineKey string) (sdConfig, error) {
202 + var m sdConfig
203 + if err := json.Unmarshal(data, &m); err != nil {
204 + return nil, fmt.Errorf("unmarshal json: %w", err)
205 + }
206 + if m == nil {
207 + return nil, fmt.Errorf("unmarshal json: got nil map")
208 + }
209 +
210 + // Force name from dyncfg job ID (matching jobmgr pattern: cfg.SetName(name))
211 + // This ensures sdConfig.Key() matches the dyncfg job ID regardless of payload content
212 + m["name"] = cleanName(name)
213 +
214 + // Add metadata
215 + m.SetSource(source)
216 + m.SetSourceType(sourceType)
217 + m.SetDiscovererType(discovererType)
218 + m.SetPipelineKey(pipelineKey)
219 + m.SetStatus(dyncfg.StatusAccepted)
220 +
221 + return m, nil
222 +}
223 +
224 +// sourceTypeFromPath determines the source type (stock/user) from a file path.
225 +func sourceTypeFromPath(path string) string {
226 + // User configs are in /etc/ (e.g., /etc/netdata/sd.d/)
227 + // Stock configs are in /usr/lib/ or similar system paths
228 + if strings.Contains(path, "/etc/") {
229 + return confgroup.TypeUser
230 + }
231 + return confgroup.TypeStock
232 +}
233 +
234 +// newLookupConfig creates a minimal sdConfig for cache lookups.
235 +// Only sets fields needed for Key() derivation: discovererType and name.
236 +func newLookupConfig(discovererType, name string) sdConfig {
237 + return sdConfig{
238 + ikeyDiscovererType: discovererType,
239 + "name": name,
240 + }
241 +}
242 +
243 +// seenSDConfigs tracks all discovered SD configs by unique ID (source + key).
244 +// Multiple sources can produce configs with the same logical name.
245 +type seenSDConfigs struct {
246 + mux sync.RWMutex
247 + items map[string]sdConfig // [UID()]
248 +}
249 +
250 +func newSeenSDConfigs() *seenSDConfigs {
251 + return &seenSDConfigs{
252 + items: make(map[string]sdConfig),
253 + }
254 +}
255 +
256 +func (c *seenSDConfigs) add(cfg sdConfig) {
257 + c.mux.Lock()
258 + defer c.mux.Unlock()
259 + c.items[cfg.UID()] = cfg
260 +}
261 +
262 +func (c *seenSDConfigs) remove(cfg sdConfig) {
263 + c.mux.Lock()
264 + defer c.mux.Unlock()
265 + delete(c.items, cfg.UID())
266 +}
267 +
268 +// lookup returns a deep copy of the config to avoid data races.
269 +// Key is derived from cfg.UID() internally.
270 +func (c *seenSDConfigs) lookup(cfg sdConfig) (sdConfig, bool) {
271 + c.mux.RLock()
272 + defer c.mux.RUnlock()
273 + v, ok := c.items[cfg.UID()]
274 + if !ok {
275 + return nil, false
276 + }
277 + return v.Clone(), true
278 +}
279 +
280 +// lookupBySource returns deep copies of configs from the given source.
281 +func (c *seenSDConfigs) lookupBySource(source string) []sdConfig {
282 + c.mux.RLock()
283 + defer c.mux.RUnlock()
284 + var result []sdConfig
285 + for _, cfg := range c.items {
286 + if cfg.Source() == source {
287 + result = append(result, cfg.Clone())
288 + }
289 + }
290 + return result
291 +}
292 +
293 +// exposedSDConfigs tracks SD configs exposed via dyncfg UI by logical key.
294 +// Only one config per logical key (discovererType:name) is exposed at a time.
295 +type exposedSDConfigs struct {
296 + mux sync.RWMutex
297 + items map[string]sdConfig // [Key()]
298 +}
299 +
300 +func newExposedSDConfigs() *exposedSDConfigs {
301 + return &exposedSDConfigs{
302 + items: make(map[string]sdConfig),
303 + }
304 +}
305 +
306 +func (c *exposedSDConfigs) add(cfg sdConfig) {
307 + c.mux.Lock()
308 + defer c.mux.Unlock()
309 + c.items[cfg.Key()] = cfg
310 +}
311 +
312 +func (c *exposedSDConfigs) remove(cfg sdConfig) {
313 + c.mux.Lock()
314 + defer c.mux.Unlock()
315 + delete(c.items, cfg.Key())
316 +}
317 +
318 +// lookup returns a deep copy of the config to avoid data races.
319 +// Key is derived from cfg.Key() internally.
320 +func (c *exposedSDConfigs) lookup(cfg sdConfig) (sdConfig, bool) {
321 + c.mux.RLock()
322 + defer c.mux.RUnlock()
323 + v, ok := c.items[cfg.Key()]
324 + if !ok {
325 + return nil, false
326 + }
327 + return v.Clone(), true
328 +}
329 +
330 +func (c *exposedSDConfigs) updateStatus(cfg sdConfig, status dyncfg.Status) {
331 + c.mux.Lock()
332 + defer c.mux.Unlock()
333 + if v, ok := c.items[cfg.Key()]; ok {
334 + v.SetStatus(status)
335 + }
336 +}
337 +
338 +func (c *exposedSDConfigs) count() int {
339 + c.mux.RLock()
340 + defer c.mux.RUnlock()
341 + return len(c.items)
342 +}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_parse.go new
+106
@@ -0,0 +1,106 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "encoding/json"
7 + "errors"
8 + "fmt"
9 + "unicode"
10 +
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
13 +
14 + "gopkg.in/yaml.v2"
15 +)
16 +
17 +// parseDyncfgPayload parses a dyncfg JSON payload into a pipeline.Config.
18 +// Since pipeline.Config now has proper JSON tags matching the schema,
19 +// we can unmarshal directly without type-specific parsing.
20 +func parseDyncfgPayload(payload []byte, discovererType string, configDefaults confgroup.Registry) (pipeline.Config, error) {
21 + var cfg pipeline.Config
22 + if err := json.Unmarshal(payload, &cfg); err != nil {
23 + return pipeline.Config{}, fmt.Errorf("unmarshal %s config: %w", discovererType, err)
24 + }
25 +
26 + cfg.ConfigDefaults = configDefaults
27 +
28 + // Validate that the config has the expected discoverer type
29 + if got := cfg.Discoverer.Type(); got != discovererType {
30 + if got == "" {
31 + return pipeline.Config{}, fmt.Errorf("no discoverer configured, expected %q", discovererType)
32 + }
33 + return pipeline.Config{}, fmt.Errorf("config has discoverer type %q, expected %q", got, discovererType)
34 + }
35 +
36 + // Perform full semantic validation (name, discoverer, services rules)
37 + if err := pipeline.ValidateConfig(cfg); err != nil {
38 + return pipeline.Config{}, err
39 + }
40 +
41 + return cfg, nil
42 +}
43 +
44 +// pipelineKey returns a unique key for a dyncfg pipeline.
45 +// Format: "dyncfg:{discovererType}:{name}"
46 +func pipelineKey(discovererType, name string) string {
47 + return fmt.Sprintf("dyncfg:%s:%s", discovererType, name)
48 +}
49 +
50 +// configToJSON converts stored config JSON to JSON via typed struct.
51 +// This ensures consistent field ordering matching the struct definition.
52 +func configToJSON(data []byte) ([]byte, error) {
53 + var cfg pipeline.Config
54 + if err := json.Unmarshal(data, &cfg); err != nil {
55 + return nil, fmt.Errorf("unmarshal json: %w", err)
56 + }
57 +
58 + bs, err := json.Marshal(cfg)
59 + if err != nil {
60 + return nil, fmt.Errorf("marshal json: %w", err)
61 + }
62 +
63 + return bs, nil
64 +}
65 +
66 +// userConfigFromPayload converts a JSON payload to YAML format for user editing.
67 +// It unmarshals JSON into pipeline.Config, then marshals to YAML.
68 +// If jobName is provided (non-empty), it overrides the name from payload.
69 +// This ensures consistent field ordering and validates the structure.
70 +func userConfigFromPayload(payload []byte, discovererType, jobName string) ([]byte, error) {
71 + var cfg pipeline.Config
72 + if err := json.Unmarshal(payload, &cfg); err != nil {
73 + return nil, fmt.Errorf("unmarshal json: %w", err)
74 + }
75 +
76 + // Use jobName if provided, otherwise keep name from payload
77 + if jobName != "" {
78 + cfg.Name = jobName
79 + }
80 + // If still no name, use default
81 + if cfg.Name == "" {
82 + cfg.Name = "test"
83 + }
84 +
85 + bs, err := yaml.Marshal(cfg)
86 + if err != nil {
87 + return nil, fmt.Errorf("marshal yaml: %w", err)
88 + }
89 +
90 + return bs, nil
91 +}
92 +
93 +// validateJobName validates a job name for dyncfg.
94 +// Job names cannot contain spaces, '.', or ':' characters.
95 +func validateJobName(jobName string) error {
96 + for _, r := range jobName {
97 + if unicode.IsSpace(r) {
98 + return errors.New("contains spaces")
99 + }
100 + switch r {
101 + case '.', ':':
102 + return fmt.Errorf("contains '%c'", r)
103 + }
104 + }
105 + return nil
106 +}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_schema.go new
+59
@@ -0,0 +1,59 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + _ "embed"
7 +)
8 +
9 +// JSON schemas for each discoverer type.
10 +// These are used by the Netdata UI to render configuration forms.
11 +
12 +//go:embed "config_schema_net_listeners.json"
13 +var schemaNetListeners string
14 +
15 +//go:embed "config_schema_docker.json"
16 +var schemaDocker string
17 +
18 +//go:embed "config_schema_k8s.json"
19 +var schemaK8s string
20 +
21 +//go:embed "config_schema_snmp.json"
22 +var schemaSNMP string
23 +
24 +var discovererSchemas = map[string]string{
25 + DiscovererNetListeners: schemaNetListeners,
26 + DiscovererDocker: schemaDocker,
27 + DiscovererK8s: schemaK8s,
28 + DiscovererSNMP: schemaSNMP,
29 +}
30 +
31 +// getDiscovererSchemaByType returns the JSON schema for a discoverer type.
32 +// If the type is not found, returns a generic placeholder schema.
33 +func getDiscovererSchemaByType(discovererType string) string {
34 + if schema, ok := discovererSchemas[discovererType]; ok {
35 + return schema
36 + }
37 + return schemaGeneric
38 +}
39 +
40 +const schemaGeneric = `{
41 + "jsonSchema": {
42 + "$schema": "http://json-schema.org/draft-07/schema#",
43 + "type": "object",
44 + "title": "Service Discovery Pipeline Configuration",
45 + "properties": {
46 + "name": {
47 + "title": "Name",
48 + "type": "string",
49 + "description": "Pipeline name (must be unique)."
50 + }
51 + },
52 + "required": ["name"]
53 + },
54 + "uiSchema": {
55 + "uiOptions": {
56 + "fullPage": true
57 + }
58 + }
59 +}`
src/go/plugin/go.d/agent/discovery/sd/dyncfg_test.go new
+2250
@@ -0,0 +1,2250 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "bytes"
7 + "context"
8 + "encoding/json"
9 + "errors"
10 + "strings"
11 + "testing"
12 + "time"
13 +
14 + "github.com/netdata/netdata/go/plugins/logger"
15 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
16 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
17 + "github.com/netdata/netdata/go/plugins/pkg/safewriter"
18 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
19 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockersd"
20 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/k8ssd"
21 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlistensd"
22 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
23 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
24 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
25 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
26 +
27 + "github.com/stretchr/testify/assert"
28 + "github.com/stretchr/testify/require"
29 +)
30 +
31 +// Helper functions to create test configs using pipeline.Config
32 +
33 +// defaultTestServices returns a minimal valid service rule for tests.
34 +func defaultTestServices() []pipeline.ServiceRuleConfig {
35 + return []pipeline.ServiceRuleConfig{
36 + {ID: "test-rule", Match: "true"},
37 + }
38 +}
39 +
40 +func newTestNetListenersConfig(name string, interval confopt.LongDuration, timeout confopt.Duration, services []pipeline.ServiceRuleConfig) pipeline.Config {
41 + return pipeline.Config{
42 + Name: name,
43 + Discoverer: pipeline.DiscovererConfig{
44 + NetListeners: &netlistensd.Config{
45 + Interval: interval,
46 + Timeout: timeout,
47 + },
48 + },
49 + Services: services,
50 + }
51 +}
52 +
53 +func newTestDockerConfig(name, address string, timeout confopt.Duration, services []pipeline.ServiceRuleConfig) pipeline.Config {
54 + return pipeline.Config{
55 + Name: name,
56 + Discoverer: pipeline.DiscovererConfig{
57 + Docker: &dockersd.Config{
58 + Address: address,
59 + Timeout: timeout,
60 + },
61 + },
62 + Services: services,
63 + }
64 +}
65 +
66 +func newTestK8sConfig(name string, cfgs []k8ssd.Config, services []pipeline.ServiceRuleConfig) pipeline.Config {
67 + return pipeline.Config{
68 + Name: name,
69 + Discoverer: pipeline.DiscovererConfig{
70 + K8s: cfgs,
71 + },
72 + Services: services,
73 + }
74 +}
75 +
76 +func newTestSNMPConfig(name string, cfg snmpsd.Config, services []pipeline.ServiceRuleConfig) pipeline.Config {
77 + return pipeline.Config{
78 + Name: name,
79 + Discoverer: pipeline.DiscovererConfig{
80 + SNMP: &cfg,
81 + },
82 + Services: services,
83 + }
84 +}
85 +
86 +type dyncfgSim struct {
87 + do func(sd *ServiceDiscovery)
88 +
89 + wantExposed []wantExposedConfig
90 + wantRunning []string
91 + wantDyncfg string
92 + wantDyncfgFunc func(t *testing.T, got string)
93 +}
94 +
95 +type wantExposedConfig struct {
96 + discovererType string
97 + name string
98 + sourceType string
99 + status dyncfg.Status
100 +}
101 +
102 +func (s *dyncfgSim) run(t *testing.T) {
103 + t.Helper()
104 +
105 + require.NotNil(t, s.do, "s.do is nil")
106 +
107 + var buf bytes.Buffer
108 + sd := &ServiceDiscovery{
109 + Logger: logger.New(),
110 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
111 + seenConfigs: newSeenSDConfigs(),
112 + exposedConfigs: newExposedSDConfigs(),
113 + dyncfgCh: make(chan dyncfg.Function, 1),
114 + newPipeline: func(cfg pipeline.Config) (sdPipeline, error) {
115 + return newTestPipeline(cfg.Name), nil
116 + },
117 + }
118 +
119 + done := make(chan struct{})
120 + ctx, cancel := context.WithCancel(context.Background())
121 +
122 + // Create output channel (we don't need to capture output for dyncfg tests)
123 + out := make(chan<- []*confgroup.Group)
124 +
125 + // Create send function
126 + send := func(ctx context.Context, groups []*confgroup.Group) {
127 + select {
128 + case <-ctx.Done():
129 + case out <- groups:
130 + }
131 + }
132 +
133 + sd.ctx = ctx
134 + sd.mgr = NewPipelineManager(sd.Logger, sd.newPipeline, send)
135 +
136 + // Register dyncfg templates (creates CONFIG entries for templates)
137 + sd.registerDyncfgTemplates(ctx)
138 +
139 + // Start processing dyncfg commands
140 + go func() {
141 + defer close(done)
142 + for {
143 + select {
144 + case <-ctx.Done():
145 + return
146 + case fn := <-sd.dyncfgCh:
147 + sd.dyncfgSeqExec(fn)
148 + }
149 + }
150 + }()
151 +
152 + timeout := time.Second * 5
153 +
154 + // Run the test scenario
155 + s.do(sd)
156 +
157 + // Give a bit of time for async operations
158 + time.Sleep(100 * time.Millisecond)
159 +
160 + cancel()
161 +
162 + select {
163 + case <-done:
164 + case <-time.After(timeout):
165 + t.Errorf("failed to finish work in %s", timeout)
166 + }
167 +
168 + // Filter and normalize dyncfg output (same approach as jobmgr sim_test.go)
169 + var lines []string
170 + for _, line := range strings.Split(buf.String(), "\n") {
171 + // Skip template CONFIG lines (registered on startup)
172 + if strings.HasPrefix(line, "CONFIG") && strings.Contains(line, " template ") {
173 + continue
174 + }
175 + // Remove timestamp from FUNCTION_RESULT_BEGIN
176 + if strings.HasPrefix(line, "FUNCTION_RESULT_BEGIN") {
177 + parts := strings.Fields(line)
178 + line = strings.Join(parts[:len(parts)-1], " ")
179 + }
180 + lines = append(lines, line)
181 + }
182 +
183 + gotDyncfg := strings.TrimSpace(strings.Join(lines, "\n"))
184 +
185 + if s.wantDyncfgFunc != nil {
186 + s.wantDyncfgFunc(t, gotDyncfg)
187 + } else if s.wantDyncfg != "" {
188 + wantDyncfg := strings.TrimSpace(s.wantDyncfg)
189 + assert.Equal(t, wantDyncfg, gotDyncfg, "dyncfg commands")
190 + }
191 +
192 + // Verify exposed configs
193 + if s.wantExposed != nil {
194 + wantLen, gotLen := len(s.wantExposed), sd.exposedConfigs.count()
195 + require.Equalf(t, wantLen, gotLen, "exposedConfigs: different len (want %d got %d)", wantLen, gotLen)
196 +
197 + for _, want := range s.wantExposed {
198 + cfg, ok := sd.exposedConfigs.lookup(newLookupConfig(want.discovererType, want.name))
199 + require.Truef(t, ok, "exposedConfigs: config '%s:%s' not found", want.discovererType, want.name)
200 + assert.Equal(t, want.sourceType, cfg.SourceType(), "exposedConfigs: wrong sourceType for '%s:%s'", want.discovererType, want.name)
201 + assert.Equal(t, want.status, cfg.Status(), "exposedConfigs: wrong status for '%s:%s'", want.discovererType, want.name)
202 + }
203 + }
204 +
205 + // Verify running pipelines
206 + if s.wantRunning != nil {
207 + gotRunning := sd.mgr.Keys()
208 + assert.ElementsMatch(t, s.wantRunning, gotRunning, "running pipelines")
209 + }
210 +}
211 +
212 +// sendDyncfgCmd sends a dyncfg command and waits for processing
213 +func sendDyncfgCmd(sd *ServiceDiscovery, uid string, args []string, payload []byte, source string) {
214 + fn := dyncfg.NewFunction(functions.Function{
215 + UID: uid,
216 + Args: args,
217 + Payload: payload,
218 + Source: source,
219 + ContentType: "application/json",
220 + })
221 +
222 + // Call dyncfgConfig directly for commands that are handled there (schema, get, userconfig)
223 + // and dyncfgSeqExec for state-changing commands
224 + cmd := ""
225 + if len(args) >= 2 {
226 + cmd = args[1]
227 + }
228 +
229 + switch cmd {
230 + case "schema", "get", "userconfig", "test":
231 + // These are handled directly in dyncfgConfig (read-only/validation commands)
232 + sd.dyncfgConfig(fn)
233 + default:
234 + // State-changing commands go through the channel
235 + select {
236 + case sd.dyncfgCh <- fn:
237 + case <-time.After(time.Second):
238 + }
239 + }
240 +
241 + // Give time for processing
242 + time.Sleep(50 * time.Millisecond)
243 +}
244 +
245 +func TestServiceDiscovery_DyncfgSchema(t *testing.T) {
246 + tests := map[string]struct {
247 + createSim func() *dyncfgSim
248 + }{
249 + "schema for net_listeners template": {
250 + createSim: func() *dyncfgSim {
251 + return &dyncfgSim{
252 + do: func(sd *ServiceDiscovery) {
253 + sendDyncfgCmd(sd, "1-schema",
254 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "schema"},
255 + nil, "")
256 + },
257 + wantDyncfgFunc: func(t *testing.T, got string) {
258 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-schema 200 application/json")
259 + assert.Contains(t, got, `"jsonSchema"`)
260 + assert.Contains(t, got, "FUNCTION_RESULT_END")
261 + },
262 + }
263 + },
264 + },
265 + "schema for unknown discoverer type": {
266 + createSim: func() *dyncfgSim {
267 + return &dyncfgSim{
268 + do: func(sd *ServiceDiscovery) {
269 + sendDyncfgCmd(sd, "1-schema",
270 + []string{sd.dyncfgSDPrefixValue() + "unknown", "schema"},
271 + nil, "")
272 + },
273 + wantDyncfgFunc: func(t *testing.T, got string) {
274 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-schema 404 application/json")
275 + assert.Contains(t, got, "Unknown discoverer type")
276 + assert.Contains(t, got, "FUNCTION_RESULT_END")
277 + },
278 + }
279 + },
280 + },
281 + }
282 +
283 + for name, tc := range tests {
284 + t.Run(name, func(t *testing.T) {
285 + sim := tc.createSim()
286 + sim.run(t)
287 + })
288 + }
289 +}
290 +
291 +func TestServiceDiscovery_DyncfgAdd(t *testing.T) {
292 + tests := map[string]struct {
293 + createSim func() *dyncfgSim
294 + }{
295 + "add net_listeners job": {
296 + createSim: func() *dyncfgSim {
297 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
298 + payload, _ := json.Marshal(cfg)
299 +
300 + return &dyncfgSim{
301 + do: func(sd *ServiceDiscovery) {
302 + sendDyncfgCmd(sd, "1-add",
303 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
304 + payload, "type=dyncfg,user=test")
305 + },
306 + wantExposed: []wantExposedConfig{
307 + {
308 + discovererType: DiscovererNetListeners,
309 + name: "test-job",
310 + sourceType: "dyncfg",
311 + status: dyncfg.StatusAccepted,
312 + },
313 + },
314 + wantDyncfg: `
315 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
316 +{"status":202,"message":""}
317 +FUNCTION_RESULT_END
318 +
319 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
320 +`,
321 + }
322 + },
323 + },
324 + "add without payload fails": {
325 + createSim: func() *dyncfgSim {
326 + return &dyncfgSim{
327 + do: func(sd *ServiceDiscovery) {
328 + sendDyncfgCmd(sd, "1-add",
329 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
330 + nil, "type=dyncfg,user=test")
331 + },
332 + wantExposed: []wantExposedConfig{},
333 + wantDyncfg: `
334 +FUNCTION_RESULT_BEGIN 1-add 400 application/json
335 +{"status":400,"errorMessage":"missing configuration payload"}
336 +FUNCTION_RESULT_END
337 +`,
338 + }
339 + },
340 + },
341 + "add duplicate job replaces existing": {
342 + createSim: func() *dyncfgSim {
343 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
344 + payload, _ := json.Marshal(cfg)
345 +
346 + return &dyncfgSim{
347 + do: func(sd *ServiceDiscovery) {
348 + // First add
349 + sendDyncfgCmd(sd, "1-add",
350 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
351 + payload, "type=dyncfg,user=test")
352 +
353 + // Second add (replaces first - matching jobmgr pattern)
354 + sendDyncfgCmd(sd, "2-add",
355 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
356 + payload, "type=dyncfg,user=test")
357 + },
358 + wantExposed: []wantExposedConfig{
359 + {
360 + discovererType: DiscovererNetListeners,
361 + name: "test-job",
362 + sourceType: "dyncfg",
363 + status: dyncfg.StatusAccepted,
364 + },
365 + },
366 + wantDyncfg: `
367 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
368 +{"status":202,"message":""}
369 +FUNCTION_RESULT_END
370 +
371 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
372 +
373 +FUNCTION_RESULT_BEGIN 2-add 202 application/json
374 +{"status":202,"message":""}
375 +FUNCTION_RESULT_END
376 +
377 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
378 +`,
379 + }
380 + },
381 + },
382 + }
383 +
384 + for name, tc := range tests {
385 + t.Run(name, func(t *testing.T) {
386 + sim := tc.createSim()
387 + sim.run(t)
388 + })
389 + }
390 +}
391 +
392 +func TestServiceDiscovery_DyncfgGet(t *testing.T) {
393 + tests := map[string]struct {
394 + createSim func() *dyncfgSim
395 + }{
396 + "get existing job": {
397 + createSim: func() *dyncfgSim {
398 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
399 + payload, _ := json.Marshal(cfg)
400 +
401 + return &dyncfgSim{
402 + do: func(sd *ServiceDiscovery) {
403 + // Add first
404 + sendDyncfgCmd(sd, "1-add",
405 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
406 + payload, "type=dyncfg,user=test")
407 +
408 + // Get
409 + sendDyncfgCmd(sd, "2-get",
410 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "get"},
411 + nil, "")
412 + },
413 + wantDyncfgFunc: func(t *testing.T, got string) {
414 + // Check that CONFIG and FUNCTION_RESULT lines are present
415 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create accepted job")
416 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
417 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-get 200 application/json")
418 + // JSON key order may vary, so check for presence of expected fields
419 + assert.Contains(t, got, `"name":"test-job"`)
420 + assert.Contains(t, got, `"discoverer":{`)
421 + assert.Contains(t, got, `"net_listeners":{}`)
422 + },
423 + }
424 + },
425 + },
426 + "get non-existent job fails": {
427 + createSim: func() *dyncfgSim {
428 + return &dyncfgSim{
429 + do: func(sd *ServiceDiscovery) {
430 + sendDyncfgCmd(sd, "1-get",
431 + []string{sd.dyncfgJobID(DiscovererNetListeners, "non-existent"), "get"},
432 + nil, "")
433 + },
434 + wantDyncfg: `
435 +FUNCTION_RESULT_BEGIN 1-get 404 application/json
436 +{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
437 +FUNCTION_RESULT_END
438 +`,
439 + }
440 + },
441 + },
442 + }
443 +
444 + for name, tc := range tests {
445 + t.Run(name, func(t *testing.T) {
446 + sim := tc.createSim()
447 + sim.run(t)
448 + })
449 + }
450 +}
451 +
452 +func TestServiceDiscovery_DyncfgEnableDisable(t *testing.T) {
453 + tests := map[string]struct {
454 + createSim func() *dyncfgSim
455 + }{
456 + "enable starts pipeline": {
457 + createSim: func() *dyncfgSim {
458 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
459 + payload, _ := json.Marshal(cfg)
460 +
461 + return &dyncfgSim{
462 + do: func(sd *ServiceDiscovery) {
463 + // Add
464 + sendDyncfgCmd(sd, "1-add",
465 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
466 + payload, "type=dyncfg,user=test")
467 +
468 + // Enable
469 + sendDyncfgCmd(sd, "2-enable",
470 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
471 + nil, "")
472 + },
473 + wantExposed: []wantExposedConfig{
474 + {
475 + discovererType: DiscovererNetListeners,
476 + name: "test-job",
477 + sourceType: "dyncfg",
478 + status: dyncfg.StatusRunning,
479 + },
480 + },
481 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
482 + wantDyncfg: `
483 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
484 +{"status":202,"message":""}
485 +FUNCTION_RESULT_END
486 +
487 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
488 +
489 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
490 +{"status":200,"message":""}
491 +FUNCTION_RESULT_END
492 +
493 +CONFIG test:sd:net_listeners:test-job status running
494 +`,
495 + }
496 + },
497 + },
498 + "disable stops pipeline": {
499 + createSim: func() *dyncfgSim {
500 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
501 + payload, _ := json.Marshal(cfg)
502 +
503 + return &dyncfgSim{
504 + do: func(sd *ServiceDiscovery) {
505 + // Add
506 + sendDyncfgCmd(sd, "1-add",
507 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
508 + payload, "type=dyncfg,user=test")
509 +
510 + // Enable
511 + sendDyncfgCmd(sd, "2-enable",
512 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
513 + nil, "")
514 +
515 + // Disable
516 + sendDyncfgCmd(sd, "3-disable",
517 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "disable"},
518 + nil, "")
519 + },
520 + wantExposed: []wantExposedConfig{
521 + {
522 + discovererType: DiscovererNetListeners,
523 + name: "test-job",
524 + sourceType: "dyncfg",
525 + status: dyncfg.StatusDisabled,
526 + },
527 + },
528 + wantRunning: []string{},
529 + wantDyncfg: `
530 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
531 +{"status":202,"message":""}
532 +FUNCTION_RESULT_END
533 +
534 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
535 +
536 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
537 +{"status":200,"message":""}
538 +FUNCTION_RESULT_END
539 +
540 +CONFIG test:sd:net_listeners:test-job status running
541 +
542 +FUNCTION_RESULT_BEGIN 3-disable 200 application/json
543 +{"status":200,"message":""}
544 +FUNCTION_RESULT_END
545 +
546 +CONFIG test:sd:net_listeners:test-job status disabled
547 +`,
548 + }
549 + },
550 + },
551 + "enable non-existent job fails": {
552 + createSim: func() *dyncfgSim {
553 + return &dyncfgSim{
554 + do: func(sd *ServiceDiscovery) {
555 + sendDyncfgCmd(sd, "1-enable",
556 + []string{sd.dyncfgJobID(DiscovererNetListeners, "non-existent"), "enable"},
557 + nil, "")
558 + },
559 + wantDyncfg: `
560 +FUNCTION_RESULT_BEGIN 1-enable 404 application/json
561 +{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
562 +FUNCTION_RESULT_END
563 +`,
564 + }
565 + },
566 + },
567 + }
568 +
569 + for name, tc := range tests {
570 + t.Run(name, func(t *testing.T) {
571 + sim := tc.createSim()
572 + sim.run(t)
573 + })
574 + }
575 +}
576 +
577 +func TestServiceDiscovery_DyncfgUpdate(t *testing.T) {
578 + tests := map[string]struct {
579 + createSim func() *dyncfgSim
580 + }{
581 + "update disabled job": {
582 + createSim: func() *dyncfgSim {
583 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
584 + payload, _ := json.Marshal(cfg)
585 +
586 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
587 + updatedPayload, _ := json.Marshal(updatedCfg)
588 +
589 + return &dyncfgSim{
590 + do: func(sd *ServiceDiscovery) {
591 + // Add
592 + sendDyncfgCmd(sd, "1-add",
593 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
594 + payload, "type=dyncfg,user=test")
595 +
596 + // Enable then disable to get to Disabled state
597 + sendDyncfgCmd(sd, "2-enable",
598 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
599 + nil, "")
600 +
601 + sendDyncfgCmd(sd, "3-disable",
602 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "disable"},
603 + nil, "")
604 +
605 + // Update (should work in Disabled state)
606 + sendDyncfgCmd(sd, "4-update",
607 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
608 + updatedPayload, "type=dyncfg,user=test")
609 + },
610 + wantExposed: []wantExposedConfig{
611 + {
612 + discovererType: DiscovererNetListeners,
613 + name: "test-job",
614 + sourceType: "dyncfg",
615 + status: dyncfg.StatusDisabled,
616 + },
617 + },
618 + wantDyncfg: `
619 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
620 +{"status":202,"message":""}
621 +FUNCTION_RESULT_END
622 +
623 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
624 +
625 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
626 +{"status":200,"message":""}
627 +FUNCTION_RESULT_END
628 +
629 +CONFIG test:sd:net_listeners:test-job status running
630 +
631 +FUNCTION_RESULT_BEGIN 3-disable 200 application/json
632 +{"status":200,"message":""}
633 +FUNCTION_RESULT_END
634 +
635 +CONFIG test:sd:net_listeners:test-job status disabled
636 +
637 +FUNCTION_RESULT_BEGIN 4-update 200 application/json
638 +{"status":200,"message":""}
639 +FUNCTION_RESULT_END
640 +
641 +CONFIG test:sd:net_listeners:test-job status disabled
642 +`,
643 + }
644 + },
645 + },
646 + "update in accepted state fails": {
647 + createSim: func() *dyncfgSim {
648 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
649 + payload, _ := json.Marshal(cfg)
650 +
651 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
652 + updatedPayload, _ := json.Marshal(updatedCfg)
653 +
654 + return &dyncfgSim{
655 + do: func(sd *ServiceDiscovery) {
656 + // Add (creates in Accepted state)
657 + sendDyncfgCmd(sd, "1-add",
658 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
659 + payload, "type=dyncfg,user=test")
660 +
661 + // Update in Accepted state should fail with 403
662 + sendDyncfgCmd(sd, "2-update",
663 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
664 + updatedPayload, "type=dyncfg,user=test")
665 + },
666 + wantExposed: []wantExposedConfig{
667 + {
668 + discovererType: DiscovererNetListeners,
669 + name: "test-job",
670 + sourceType: "dyncfg",
671 + status: dyncfg.StatusAccepted,
672 + },
673 + },
674 + wantDyncfg: `
675 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
676 +{"status":202,"message":""}
677 +FUNCTION_RESULT_END
678 +
679 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
680 +
681 +FUNCTION_RESULT_BEGIN 2-update 403 application/json
682 +{"status":403,"errorMessage":"Updating is not allowed in 'accepted' state."}
683 +FUNCTION_RESULT_END
684 +
685 +CONFIG test:sd:net_listeners:test-job status accepted
686 +`,
687 + }
688 + },
689 + },
690 + "update non-existent job fails": {
691 + createSim: func() *dyncfgSim {
692 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
693 + payload, _ := json.Marshal(cfg)
694 +
695 + return &dyncfgSim{
696 + do: func(sd *ServiceDiscovery) {
697 + sendDyncfgCmd(sd, "1-update",
698 + []string{sd.dyncfgJobID(DiscovererNetListeners, "non-existent"), "update"},
699 + payload, "type=dyncfg,user=test")
700 + },
701 + wantDyncfg: `
702 +FUNCTION_RESULT_BEGIN 1-update 404 application/json
703 +{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
704 +FUNCTION_RESULT_END
705 +`,
706 + }
707 + },
708 + },
709 + }
710 +
711 + for name, tc := range tests {
712 + t.Run(name, func(t *testing.T) {
713 + sim := tc.createSim()
714 + sim.run(t)
715 + })
716 + }
717 +}
718 +
719 +func TestServiceDiscovery_DyncfgRemove(t *testing.T) {
720 + tests := map[string]struct {
721 + createSim func() *dyncfgSim
722 + }{
723 + "remove dyncfg job": {
724 + createSim: func() *dyncfgSim {
725 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
726 + payload, _ := json.Marshal(cfg)
727 +
728 + return &dyncfgSim{
729 + do: func(sd *ServiceDiscovery) {
730 + // Add
731 + sendDyncfgCmd(sd, "1-add",
732 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
733 + payload, "type=dyncfg,user=test")
734 +
735 + // Remove
736 + sendDyncfgCmd(sd, "2-remove",
737 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "remove"},
738 + nil, "")
739 + },
740 + wantExposed: []wantExposedConfig{},
741 + wantRunning: []string{},
742 + wantDyncfg: `
743 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
744 +{"status":202,"message":""}
745 +FUNCTION_RESULT_END
746 +
747 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
748 +
749 +FUNCTION_RESULT_BEGIN 2-remove 200 application/json
750 +{"status":200,"message":""}
751 +FUNCTION_RESULT_END
752 +
753 +CONFIG test:sd:net_listeners:test-job delete
754 +`,
755 + }
756 + },
757 + },
758 + "remove running job stops it first": {
759 + createSim: func() *dyncfgSim {
760 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
761 + payload, _ := json.Marshal(cfg)
762 +
763 + return &dyncfgSim{
764 + do: func(sd *ServiceDiscovery) {
765 + // Add
766 + sendDyncfgCmd(sd, "1-add",
767 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
768 + payload, "type=dyncfg,user=test")
769 +
770 + // Enable
771 + sendDyncfgCmd(sd, "2-enable",
772 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
773 + nil, "")
774 +
775 + // Remove (should stop first)
776 + sendDyncfgCmd(sd, "3-remove",
777 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "remove"},
778 + nil, "")
779 + },
780 + wantExposed: []wantExposedConfig{},
781 + wantRunning: []string{},
782 + wantDyncfg: `
783 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
784 +{"status":202,"message":""}
785 +FUNCTION_RESULT_END
786 +
787 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
788 +
789 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
790 +{"status":200,"message":""}
791 +FUNCTION_RESULT_END
792 +
793 +CONFIG test:sd:net_listeners:test-job status running
794 +
795 +FUNCTION_RESULT_BEGIN 3-remove 200 application/json
796 +{"status":200,"message":""}
797 +FUNCTION_RESULT_END
798 +
799 +CONFIG test:sd:net_listeners:test-job delete
800 +`,
801 + }
802 + },
803 + },
804 + "remove non-existent job fails": {
805 + createSim: func() *dyncfgSim {
806 + return &dyncfgSim{
807 + do: func(sd *ServiceDiscovery) {
808 + sendDyncfgCmd(sd, "1-remove",
809 + []string{sd.dyncfgJobID(DiscovererNetListeners, "non-existent"), "remove"},
810 + nil, "")
811 + },
812 + wantDyncfg: `
813 +FUNCTION_RESULT_BEGIN 1-remove 404 application/json
814 +{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
815 +FUNCTION_RESULT_END
816 +`,
817 + }
818 + },
819 + },
820 + }
821 +
822 + for name, tc := range tests {
823 + t.Run(name, func(t *testing.T) {
824 + sim := tc.createSim()
825 + sim.run(t)
826 + })
827 + }
828 +}
829 +
830 +func TestServiceDiscovery_DyncfgUserconfig(t *testing.T) {
831 + tests := map[string]struct {
832 + createSim func() *dyncfgSim
833 + }{
834 + "userconfig for template": {
835 + createSim: func() *dyncfgSim {
836 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
837 + payload, _ := json.Marshal(cfg)
838 +
839 + return &dyncfgSim{
840 + do: func(sd *ServiceDiscovery) {
841 + sendDyncfgCmd(sd, "1-userconfig",
842 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "userconfig"},
843 + payload, "")
844 + },
845 + wantDyncfgFunc: func(t *testing.T, got string) {
846 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-userconfig 200 application/yaml")
847 + assert.Contains(t, got, "name: test-job")
848 + assert.Contains(t, got, "interval: 5")
849 + assert.Contains(t, got, "FUNCTION_RESULT_END")
850 + },
851 + }
852 + },
853 + },
854 + "userconfig for existing job": {
855 + createSim: func() *dyncfgSim {
856 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
857 + payload, _ := json.Marshal(cfg)
858 +
859 + return &dyncfgSim{
860 + do: func(sd *ServiceDiscovery) {
861 + // Add first
862 + sendDyncfgCmd(sd, "1-add",
863 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
864 + payload, "type=dyncfg,user=test")
865 +
866 + // Userconfig - must provide payload (matching jobmgr pattern)
867 + sendDyncfgCmd(sd, "2-userconfig",
868 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "userconfig"},
869 + payload, "")
870 + },
871 + wantDyncfgFunc: func(t *testing.T, got string) {
872 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
873 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-userconfig 200 application/yaml")
874 + assert.Contains(t, got, "name: test-job")
875 + assert.Contains(t, got, "interval: 5")
876 + },
877 + }
878 + },
879 + },
880 + }
881 +
882 + for name, tc := range tests {
883 + t.Run(name, func(t *testing.T) {
884 + sim := tc.createSim()
885 + sim.run(t)
886 + })
887 + }
888 +}
889 +
890 +func TestServiceDiscovery_DyncfgFileConfig(t *testing.T) {
891 + tests := map[string]struct {
892 + createSim func() *dyncfgSim
893 + }{
894 + "file config cannot be removed via dyncfg": {
895 + createSim: func() *dyncfgSim {
896 + return &dyncfgSim{
897 + do: func(sd *ServiceDiscovery) {
898 + // Manually add a file-based config to exposedConfigs
899 + cfg := sdConfig{
900 + "name": "file-config",
901 + ikeyDiscovererType: DiscovererNetListeners,
902 + ikeyPipelineKey: "/etc/netdata/sd/test.conf",
903 + ikeySource: "/etc/netdata/sd/test.conf",
904 + ikeySourceType: "file",
905 + ikeyStatus: dyncfg.StatusRunning,
906 + }
907 + sd.exposedConfigs.add(cfg)
908 +
909 + // Try to remove
910 + sendDyncfgCmd(sd, "1-remove",
911 + []string{sd.dyncfgJobID(DiscovererNetListeners, "file-config"), "remove"},
912 + nil, "")
913 + },
914 + wantExposed: []wantExposedConfig{
915 + {
916 + discovererType: DiscovererNetListeners,
917 + name: "file-config",
918 + sourceType: "file",
919 + status: dyncfg.StatusRunning,
920 + },
921 + },
922 + wantDyncfg: `
923 +FUNCTION_RESULT_BEGIN 1-remove 405 application/json
924 +{"status":405,"errorMessage":"Cannot remove non-dyncfg configs. Source type: file"}
925 +FUNCTION_RESULT_END
926 +`,
927 + }
928 + },
929 + },
930 + }
931 +
932 + for name, tc := range tests {
933 + t.Run(name, func(t *testing.T) {
934 + sim := tc.createSim()
935 + sim.run(t)
936 + })
937 + }
938 +}
939 +
940 +func TestServiceDiscovery_DyncfgDockerConfig(t *testing.T) {
941 + tests := map[string]struct {
942 + createSim func() *dyncfgSim
943 + }{
944 + "add docker job": {
945 + createSim: func() *dyncfgSim {
946 + cfg := newTestDockerConfig("docker-test", "unix:///var/run/docker.sock", confopt.Duration(5*time.Second), []pipeline.ServiceRuleConfig{
947 + {ID: "nginx", Match: `{{ glob .Image "*nginx*" }}`},
948 + })
949 + payload, _ := json.Marshal(cfg)
950 +
951 + return &dyncfgSim{
952 + do: func(sd *ServiceDiscovery) {
953 + sendDyncfgCmd(sd, "1-add",
954 + []string{sd.dyncfgTemplateID(DiscovererDocker), "add", "docker-test"},
955 + payload, "type=dyncfg,user=test")
956 + },
957 + wantExposed: []wantExposedConfig{
958 + {
959 + discovererType: DiscovererDocker,
960 + name: "docker-test",
961 + sourceType: "dyncfg",
962 + status: dyncfg.StatusAccepted,
963 + },
964 + },
965 + wantDyncfg: `
966 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
967 +{"status":202,"message":""}
968 +FUNCTION_RESULT_END
969 +
970 +CONFIG test:sd:docker:docker-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
971 +`,
972 + }
973 + },
974 + },
975 + "add and enable docker job": {
976 + createSim: func() *dyncfgSim {
977 + cfg := newTestDockerConfig("docker-test", "tcp://localhost:2375", 0, defaultTestServices())
978 + payload, _ := json.Marshal(cfg)
979 +
980 + return &dyncfgSim{
981 + do: func(sd *ServiceDiscovery) {
982 + // Add
983 + sendDyncfgCmd(sd, "1-add",
984 + []string{sd.dyncfgTemplateID(DiscovererDocker), "add", "docker-test"},
985 + payload, "type=dyncfg,user=test")
986 +
987 + // Enable
988 + sendDyncfgCmd(sd, "2-enable",
989 + []string{sd.dyncfgJobID(DiscovererDocker, "docker-test"), "enable"},
990 + nil, "")
991 + },
992 + wantExposed: []wantExposedConfig{
993 + {
994 + discovererType: DiscovererDocker,
995 + name: "docker-test",
996 + sourceType: "dyncfg",
997 + status: dyncfg.StatusRunning,
998 + },
999 + },
1000 + wantRunning: []string{"dyncfg:docker:docker-test"},
1001 + }
1002 + },
1003 + },
1004 + "get docker job config": {
1005 + createSim: func() *dyncfgSim {
1006 + cfg := newTestDockerConfig("docker-test", "unix:///var/run/docker.sock", 0, defaultTestServices())
1007 + payload, _ := json.Marshal(cfg)
1008 +
1009 + return &dyncfgSim{
1010 + do: func(sd *ServiceDiscovery) {
1011 + // Add
1012 + sendDyncfgCmd(sd, "1-add",
1013 + []string{sd.dyncfgTemplateID(DiscovererDocker), "add", "docker-test"},
1014 + payload, "type=dyncfg,user=test")
1015 +
1016 + // Get
1017 + sendDyncfgCmd(sd, "2-get",
1018 + []string{sd.dyncfgJobID(DiscovererDocker, "docker-test"), "get"},
1019 + nil, "")
1020 + },
1021 + wantDyncfgFunc: func(t *testing.T, got string) {
1022 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-get 200 application/json")
1023 + assert.Contains(t, got, `"name":"docker-test"`)
1024 + assert.Contains(t, got, `"address":"unix:///var/run/docker.sock"`)
1025 + },
1026 + }
1027 + },
1028 + },
1029 + }
1030 +
1031 + for name, tc := range tests {
1032 + t.Run(name, func(t *testing.T) {
1033 + sim := tc.createSim()
1034 + sim.run(t)
1035 + })
1036 + }
1037 +}
1038 +
1039 +func TestServiceDiscovery_DyncfgK8sConfig(t *testing.T) {
1040 + tests := map[string]struct {
1041 + createSim func() *dyncfgSim
1042 + }{
1043 + "add k8s job": {
1044 + createSim: func() *dyncfgSim {
1045 + k8sCfg := k8ssd.Config{
1046 + Role: "pod",
1047 + Namespaces: []string{"default", "kube-system"},
1048 + }
1049 + k8sCfg.Selector.Label = "app=nginx"
1050 + k8sCfg.Pod.LocalMode = true
1051 + cfg := newTestK8sConfig("k8s-test", []k8ssd.Config{k8sCfg}, []pipeline.ServiceRuleConfig{
1052 + {ID: "nginx-pods", Match: `{{ eq .Namespace "default" }}`},
1053 + })
1054 + payload, _ := json.Marshal(cfg)
1055 +
1056 + return &dyncfgSim{
1057 + do: func(sd *ServiceDiscovery) {
1058 + sendDyncfgCmd(sd, "1-add",
1059 + []string{sd.dyncfgTemplateID(DiscovererK8s), "add", "k8s-test"},
1060 + payload, "type=dyncfg,user=test")
1061 + },
1062 + wantExposed: []wantExposedConfig{
1063 + {
1064 + discovererType: DiscovererK8s,
1065 + name: "k8s-test",
1066 + sourceType: "dyncfg",
1067 + status: dyncfg.StatusAccepted,
1068 + },
1069 + },
1070 + wantDyncfg: `
1071 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
1072 +{"status":202,"message":""}
1073 +FUNCTION_RESULT_END
1074 +
1075 +CONFIG test:sd:k8s:k8s-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1076 +`,
1077 + }
1078 + },
1079 + },
1080 + "add k8s service role job": {
1081 + createSim: func() *dyncfgSim {
1082 + cfg := newTestK8sConfig("k8s-svc-test", []k8ssd.Config{{Role: "service"}}, defaultTestServices())
1083 + payload, _ := json.Marshal(cfg)
1084 +
1085 + return &dyncfgSim{
1086 + do: func(sd *ServiceDiscovery) {
1087 + // Add
1088 + sendDyncfgCmd(sd, "1-add",
1089 + []string{sd.dyncfgTemplateID(DiscovererK8s), "add", "k8s-svc-test"},
1090 + payload, "type=dyncfg,user=test")
1091 +
1092 + // Enable
1093 + sendDyncfgCmd(sd, "2-enable",
1094 + []string{sd.dyncfgJobID(DiscovererK8s, "k8s-svc-test"), "enable"},
1095 + nil, "")
1096 + },
1097 + wantExposed: []wantExposedConfig{
1098 + {
1099 + discovererType: DiscovererK8s,
1100 + name: "k8s-svc-test",
1101 + sourceType: "dyncfg",
1102 + status: dyncfg.StatusRunning,
1103 + },
1104 + },
1105 + wantRunning: []string{"dyncfg:k8s:k8s-svc-test"},
1106 + }
1107 + },
1108 + },
1109 + "get k8s job config": {
1110 + createSim: func() *dyncfgSim {
1111 + cfg := newTestK8sConfig("k8s-test", []k8ssd.Config{{Role: "pod", Namespaces: []string{"default"}}}, defaultTestServices())
1112 + payload, _ := json.Marshal(cfg)
1113 +
1114 + return &dyncfgSim{
1115 + do: func(sd *ServiceDiscovery) {
1116 + // Add
1117 + sendDyncfgCmd(sd, "1-add",
1118 + []string{sd.dyncfgTemplateID(DiscovererK8s), "add", "k8s-test"},
1119 + payload, "type=dyncfg,user=test")
1120 +
1121 + // Get
1122 + sendDyncfgCmd(sd, "2-get",
1123 + []string{sd.dyncfgJobID(DiscovererK8s, "k8s-test"), "get"},
1124 + nil, "")
1125 + },
1126 + wantDyncfgFunc: func(t *testing.T, got string) {
1127 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-get 200 application/json")
1128 + assert.Contains(t, got, `"name":"k8s-test"`)
1129 + assert.Contains(t, got, `"role":"pod"`)
1130 + assert.Contains(t, got, `"namespaces":["default"]`)
1131 + },
1132 + }
1133 + },
1134 + },
1135 + }
1136 +
1137 + for name, tc := range tests {
1138 + t.Run(name, func(t *testing.T) {
1139 + sim := tc.createSim()
1140 + sim.run(t)
1141 + })
1142 + }
1143 +}
1144 +
1145 +func TestServiceDiscovery_DyncfgSNMPConfig(t *testing.T) {
1146 + tests := map[string]struct {
1147 + createSim func() *dyncfgSim
1148 + }{
1149 + "add snmp job": {
1150 + createSim: func() *dyncfgSim {
1151 + cfg := newTestSNMPConfig("snmp-test", snmpsd.Config{
1152 + RescanInterval: confopt.LongDuration(30 * time.Minute),
1153 + Timeout: confopt.Duration(1 * time.Second),
1154 + DeviceCacheTTL: confopt.LongDuration(12 * time.Hour),
1155 + Credentials: []snmpsd.CredentialConfig{{Name: "public-v2", Version: "2c", Community: "public"}},
1156 + Networks: []snmpsd.NetworkConfig{{Subnet: "192.168.1.0/24", Credential: "public-v2"}},
1157 + }, defaultTestServices())
1158 + payload, _ := json.Marshal(cfg)
1159 +
1160 + return &dyncfgSim{
1161 + do: func(sd *ServiceDiscovery) {
1162 + sendDyncfgCmd(sd, "1-add",
1163 + []string{sd.dyncfgTemplateID(DiscovererSNMP), "add", "snmp-test"},
1164 + payload, "type=dyncfg,user=test")
1165 + },
1166 + wantExposed: []wantExposedConfig{
1167 + {
1168 + discovererType: DiscovererSNMP,
1169 + name: "snmp-test",
1170 + sourceType: "dyncfg",
1171 + status: dyncfg.StatusAccepted,
1172 + },
1173 + },
1174 + wantDyncfg: `
1175 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
1176 +{"status":202,"message":""}
1177 +FUNCTION_RESULT_END
1178 +
1179 +CONFIG test:sd:snmp:snmp-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1180 +`,
1181 + }
1182 + },
1183 + },
1184 + "add snmp v3 job": {
1185 + createSim: func() *dyncfgSim {
1186 + cfg := newTestSNMPConfig("snmp-v3-test", snmpsd.Config{
1187 + Credentials: []snmpsd.CredentialConfig{{
1188 + Name: "snmpv3-auth",
1189 + Version: "3",
1190 + UserName: "admin",
1191 + SecurityLevel: "authPriv",
1192 + AuthProtocol: "sha256",
1193 + AuthPassphrase: "authpass",
1194 + PrivacyProtocol: "aes",
1195 + PrivacyPassphrase: "privpass",
1196 + }},
1197 + Networks: []snmpsd.NetworkConfig{{Subnet: "10.0.0.0/24", Credential: "snmpv3-auth"}},
1198 + }, defaultTestServices())
1199 + payload, _ := json.Marshal(cfg)
1200 +
1201 + return &dyncfgSim{
1202 + do: func(sd *ServiceDiscovery) {
1203 + // Add
1204 + sendDyncfgCmd(sd, "1-add",
1205 + []string{sd.dyncfgTemplateID(DiscovererSNMP), "add", "snmp-v3-test"},
1206 + payload, "type=dyncfg,user=test")
1207 +
1208 + // Enable
1209 + sendDyncfgCmd(sd, "2-enable",
1210 + []string{sd.dyncfgJobID(DiscovererSNMP, "snmp-v3-test"), "enable"},
1211 + nil, "")
1212 + },
1213 + wantExposed: []wantExposedConfig{
1214 + {
1215 + discovererType: DiscovererSNMP,
1216 + name: "snmp-v3-test",
1217 + sourceType: "dyncfg",
1218 + status: dyncfg.StatusRunning,
1219 + },
1220 + },
1221 + wantRunning: []string{"dyncfg:snmp:snmp-v3-test"},
1222 + }
1223 + },
1224 + },
1225 + "get snmp job config": {
1226 + createSim: func() *dyncfgSim {
1227 + cfg := newTestSNMPConfig("snmp-test", snmpsd.Config{
1228 + RescanInterval: confopt.LongDuration(1 * time.Hour),
1229 + Credentials: []snmpsd.CredentialConfig{{Name: "v2-cred", Version: "2c", Community: "public"}},
1230 + Networks: []snmpsd.NetworkConfig{{Subnet: "192.168.0.0/16", Credential: "v2-cred"}},
1231 + }, defaultTestServices())
1232 + payload, _ := json.Marshal(cfg)
1233 +
1234 + return &dyncfgSim{
1235 + do: func(sd *ServiceDiscovery) {
1236 + // Add
1237 + sendDyncfgCmd(sd, "1-add",
1238 + []string{sd.dyncfgTemplateID(DiscovererSNMP), "add", "snmp-test"},
1239 + payload, "type=dyncfg,user=test")
1240 +
1241 + // Get
1242 + sendDyncfgCmd(sd, "2-get",
1243 + []string{sd.dyncfgJobID(DiscovererSNMP, "snmp-test"), "get"},
1244 + nil, "")
1245 + },
1246 + wantDyncfgFunc: func(t *testing.T, got string) {
1247 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-get 200 application/json")
1248 + assert.Contains(t, got, `"name":"snmp-test"`)
1249 + assert.Contains(t, got, `"rescan_interval":"1h"`)
1250 + assert.Contains(t, got, `"subnet":"192.168.0.0/16"`)
1251 + },
1252 + }
1253 + },
1254 + },
1255 + }
1256 +
1257 + for name, tc := range tests {
1258 + t.Run(name, func(t *testing.T) {
1259 + sim := tc.createSim()
1260 + sim.run(t)
1261 + })
1262 + }
1263 +}
1264 +
1265 +func TestServiceDiscovery_DyncfgUpdateWhileRunning(t *testing.T) {
1266 + tests := map[string]struct {
1267 + createSim func() *dyncfgSim
1268 + }{
1269 + "update running pipeline restarts it": {
1270 + createSim: func() *dyncfgSim {
1271 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
1272 + payload, _ := json.Marshal(cfg)
1273 +
1274 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
1275 + updatedPayload, _ := json.Marshal(updatedCfg)
1276 +
1277 + return &dyncfgSim{
1278 + do: func(sd *ServiceDiscovery) {
1279 + // Add
1280 + sendDyncfgCmd(sd, "1-add",
1281 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1282 + payload, "type=dyncfg,user=test")
1283 +
1284 + // Enable (starts pipeline)
1285 + sendDyncfgCmd(sd, "2-enable",
1286 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
1287 + nil, "")
1288 +
1289 + // Update while running (should restart pipeline)
1290 + sendDyncfgCmd(sd, "3-update",
1291 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
1292 + updatedPayload, "type=dyncfg,user=test")
1293 + },
1294 + wantExposed: []wantExposedConfig{
1295 + {
1296 + discovererType: DiscovererNetListeners,
1297 + name: "test-job",
1298 + sourceType: "dyncfg",
1299 + status: dyncfg.StatusRunning,
1300 + },
1301 + },
1302 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
1303 + wantDyncfg: `
1304 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
1305 +{"status":202,"message":""}
1306 +FUNCTION_RESULT_END
1307 +
1308 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1309 +
1310 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
1311 +{"status":200,"message":""}
1312 +FUNCTION_RESULT_END
1313 +
1314 +CONFIG test:sd:net_listeners:test-job status running
1315 +
1316 +FUNCTION_RESULT_BEGIN 3-update 200 application/json
1317 +{"status":200,"message":""}
1318 +FUNCTION_RESULT_END
1319 +
1320 +CONFIG test:sd:net_listeners:test-job status running
1321 +`,
1322 + }
1323 + },
1324 + },
1325 + "update running docker pipeline": {
1326 + createSim: func() *dyncfgSim {
1327 + cfg := newTestDockerConfig("docker-job", "unix:///var/run/docker.sock", 0, defaultTestServices())
1328 + payload, _ := json.Marshal(cfg)
1329 +
1330 + updatedCfg := newTestDockerConfig("docker-job", "tcp://localhost:2375", 0, defaultTestServices())
1331 + updatedPayload, _ := json.Marshal(updatedCfg)
1332 +
1333 + return &dyncfgSim{
1334 + do: func(sd *ServiceDiscovery) {
1335 + // Add
1336 + sendDyncfgCmd(sd, "1-add",
1337 + []string{sd.dyncfgTemplateID(DiscovererDocker), "add", "docker-job"},
1338 + payload, "type=dyncfg,user=test")
1339 +
1340 + // Enable
1341 + sendDyncfgCmd(sd, "2-enable",
1342 + []string{sd.dyncfgJobID(DiscovererDocker, "docker-job"), "enable"},
1343 + nil, "")
1344 +
1345 + // Update while running
1346 + sendDyncfgCmd(sd, "3-update",
1347 + []string{sd.dyncfgJobID(DiscovererDocker, "docker-job"), "update"},
1348 + updatedPayload, "type=dyncfg,user=test")
1349 +
1350 + // Verify config was updated
1351 + sendDyncfgCmd(sd, "4-get",
1352 + []string{sd.dyncfgJobID(DiscovererDocker, "docker-job"), "get"},
1353 + nil, "")
1354 + },
1355 + wantExposed: []wantExposedConfig{
1356 + {
1357 + discovererType: DiscovererDocker,
1358 + name: "docker-job",
1359 + sourceType: "dyncfg",
1360 + status: dyncfg.StatusRunning,
1361 + },
1362 + },
1363 + wantRunning: []string{"dyncfg:docker:docker-job"},
1364 + wantDyncfgFunc: func(t *testing.T, got string) {
1365 + // Verify update response
1366 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 3-update 200 application/json")
1367 + // Verify config was updated to new address
1368 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 4-get 200 application/json")
1369 + assert.Contains(t, got, `"address":"tcp://localhost:2375"`)
1370 + },
1371 + }
1372 + },
1373 + },
1374 + }
1375 +
1376 + for name, tc := range tests {
1377 + t.Run(name, func(t *testing.T) {
1378 + sim := tc.createSim()
1379 + sim.run(t)
1380 + })
1381 + }
1382 +}
1383 +
1384 +func TestServiceDiscovery_DyncfgTest(t *testing.T) {
1385 + tests := map[string]struct {
1386 + createSim func() *dyncfgSim
1387 + }{
1388 + "test valid config succeeds": {
1389 + createSim: func() *dyncfgSim {
1390 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
1391 + payload, _ := json.Marshal(cfg)
1392 +
1393 + return &dyncfgSim{
1394 + do: func(sd *ServiceDiscovery) {
1395 + sendDyncfgCmd(sd, "1-test",
1396 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "test"},
1397 + payload, "")
1398 + },
1399 + wantExposed: []wantExposedConfig{},
1400 + wantRunning: []string{},
1401 + wantDyncfg: `
1402 +FUNCTION_RESULT_BEGIN 1-test 200 application/json
1403 +{"status":200,"message":""}
1404 +FUNCTION_RESULT_END
1405 +`,
1406 + }
1407 + },
1408 + },
1409 + "test without payload fails": {
1410 + createSim: func() *dyncfgSim {
1411 + return &dyncfgSim{
1412 + do: func(sd *ServiceDiscovery) {
1413 + sendDyncfgCmd(sd, "1-test",
1414 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "test"},
1415 + nil, "")
1416 + },
1417 + wantExposed: []wantExposedConfig{},
1418 + wantRunning: []string{},
1419 + wantDyncfg: `
1420 +FUNCTION_RESULT_BEGIN 1-test 400 application/json
1421 +{"status":400,"errorMessage":"missing configuration payload"}
1422 +FUNCTION_RESULT_END
1423 +`,
1424 + }
1425 + },
1426 + },
1427 + "test valid docker config": {
1428 + createSim: func() *dyncfgSim {
1429 + cfg := newTestDockerConfig("docker-test", "unix:///var/run/docker.sock", 0, defaultTestServices())
1430 + payload, _ := json.Marshal(cfg)
1431 +
1432 + return &dyncfgSim{
1433 + do: func(sd *ServiceDiscovery) {
1434 + sendDyncfgCmd(sd, "1-test",
1435 + []string{sd.dyncfgTemplateID(DiscovererDocker), "test"},
1436 + payload, "")
1437 + },
1438 + wantExposed: []wantExposedConfig{},
1439 + wantRunning: []string{},
1440 + wantDyncfg: `
1441 +FUNCTION_RESULT_BEGIN 1-test 200 application/json
1442 +{"status":200,"message":""}
1443 +FUNCTION_RESULT_END
1444 +`,
1445 + }
1446 + },
1447 + },
1448 + "test valid k8s config": {
1449 + createSim: func() *dyncfgSim {
1450 + cfg := newTestK8sConfig("k8s-test", []k8ssd.Config{{Role: "pod"}}, defaultTestServices())
1451 + payload, _ := json.Marshal(cfg)
1452 +
1453 + return &dyncfgSim{
1454 + do: func(sd *ServiceDiscovery) {
1455 + sendDyncfgCmd(sd, "1-test",
1456 + []string{sd.dyncfgTemplateID(DiscovererK8s), "test"},
1457 + payload, "")
1458 + },
1459 + wantExposed: []wantExposedConfig{},
1460 + wantRunning: []string{},
1461 + wantDyncfg: `
1462 +FUNCTION_RESULT_BEGIN 1-test 200 application/json
1463 +{"status":200,"message":""}
1464 +FUNCTION_RESULT_END
1465 +`,
1466 + }
1467 + },
1468 + },
1469 + "test valid snmp config": {
1470 + createSim: func() *dyncfgSim {
1471 + cfg := newTestSNMPConfig("snmp-test", snmpsd.Config{
1472 + Credentials: []snmpsd.CredentialConfig{{Name: "v2-cred", Version: "2c"}},
1473 + Networks: []snmpsd.NetworkConfig{{Subnet: "192.168.1.0/24", Credential: "v2-cred"}},
1474 + }, defaultTestServices())
1475 + payload, _ := json.Marshal(cfg)
1476 +
1477 + return &dyncfgSim{
1478 + do: func(sd *ServiceDiscovery) {
1479 + sendDyncfgCmd(sd, "1-test",
1480 + []string{sd.dyncfgTemplateID(DiscovererSNMP), "test"},
1481 + payload, "")
1482 + },
1483 + wantExposed: []wantExposedConfig{},
1484 + wantRunning: []string{},
1485 + wantDyncfg: `
1486 +FUNCTION_RESULT_BEGIN 1-test 200 application/json
1487 +{"status":200,"message":""}
1488 +FUNCTION_RESULT_END
1489 +`,
1490 + }
1491 + },
1492 + },
1493 + "test existing job config succeeds": {
1494 + createSim: func() *dyncfgSim {
1495 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
1496 + payload, _ := json.Marshal(cfg)
1497 +
1498 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
1499 + updatedPayload, _ := json.Marshal(updatedCfg)
1500 +
1501 + return &dyncfgSim{
1502 + do: func(sd *ServiceDiscovery) {
1503 + // First add the job
1504 + sendDyncfgCmd(sd, "1-add",
1505 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1506 + payload, "type=dyncfg,user=test")
1507 +
1508 + // Test with new config (validates without applying)
1509 + sendDyncfgCmd(sd, "2-test",
1510 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "test"},
1511 + updatedPayload, "")
1512 + },
1513 + wantExposed: []wantExposedConfig{
1514 + {
1515 + discovererType: DiscovererNetListeners,
1516 + name: "test-job",
1517 + sourceType: "dyncfg",
1518 + status: dyncfg.StatusAccepted, // Still accepted, not changed by test
1519 + },
1520 + },
1521 + wantDyncfgFunc: func(t *testing.T, got string) {
1522 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
1523 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-test 200 application/json")
1524 + },
1525 + }
1526 + },
1527 + },
1528 + "test job with invalid config fails": {
1529 + createSim: func() *dyncfgSim {
1530 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
1531 + payload, _ := json.Marshal(cfg)
1532 +
1533 + return &dyncfgSim{
1534 + do: func(sd *ServiceDiscovery) {
1535 + // First add the job
1536 + sendDyncfgCmd(sd, "1-add",
1537 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1538 + payload, "type=dyncfg,user=test")
1539 +
1540 + // Test with invalid JSON
1541 + sendDyncfgCmd(sd, "2-test",
1542 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "test"},
1543 + []byte("{invalid json}"), "")
1544 + },
1545 + wantExposed: []wantExposedConfig{
1546 + {
1547 + discovererType: DiscovererNetListeners,
1548 + name: "test-job",
1549 + sourceType: "dyncfg",
1550 + status: dyncfg.StatusAccepted,
1551 + },
1552 + },
1553 + wantDyncfgFunc: func(t *testing.T, got string) {
1554 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
1555 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 2-test 400 application/json")
1556 + assert.Contains(t, got, "Failed to parse config")
1557 + },
1558 + }
1559 + },
1560 + },
1561 + }
1562 +
1563 + for name, tc := range tests {
1564 + t.Run(name, func(t *testing.T) {
1565 + sim := tc.createSim()
1566 + sim.run(t)
1567 + })
1568 + }
1569 +}
1570 +
1571 +func TestServiceDiscovery_DyncfgMultipleJobs(t *testing.T) {
1572 + tests := map[string]struct {
1573 + createSim func() *dyncfgSim
1574 + }{
1575 + "multiple jobs lifecycle": {
1576 + createSim: func() *dyncfgSim {
1577 + cfg1 := newTestNetListenersConfig("job1", 0, 0, defaultTestServices())
1578 + cfg2 := newTestNetListenersConfig("job2", 0, 0, defaultTestServices())
1579 + payload1, _ := json.Marshal(cfg1)
1580 + payload2, _ := json.Marshal(cfg2)
1581 +
1582 + return &dyncfgSim{
1583 + do: func(sd *ServiceDiscovery) {
1584 + // Add job1
1585 + sendDyncfgCmd(sd, "1-add",
1586 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "job1"},
1587 + payload1, "type=dyncfg,user=test")
1588 +
1589 + // Add job2
1590 + sendDyncfgCmd(sd, "2-add",
1591 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "job2"},
1592 + payload2, "type=dyncfg,user=test")
1593 +
1594 + // Enable both
1595 + sendDyncfgCmd(sd, "3-enable",
1596 + []string{sd.dyncfgJobID(DiscovererNetListeners, "job1"), "enable"},
1597 + nil, "")
1598 + sendDyncfgCmd(sd, "4-enable",
1599 + []string{sd.dyncfgJobID(DiscovererNetListeners, "job2"), "enable"},
1600 + nil, "")
1601 +
1602 + // Disable job1
1603 + sendDyncfgCmd(sd, "5-disable",
1604 + []string{sd.dyncfgJobID(DiscovererNetListeners, "job1"), "disable"},
1605 + nil, "")
1606 +
1607 + // Remove job2
1608 + sendDyncfgCmd(sd, "6-remove",
1609 + []string{sd.dyncfgJobID(DiscovererNetListeners, "job2"), "remove"},
1610 + nil, "")
1611 + },
1612 + wantExposed: []wantExposedConfig{
1613 + {
1614 + discovererType: DiscovererNetListeners,
1615 + name: "job1",
1616 + sourceType: "dyncfg",
1617 + status: dyncfg.StatusDisabled,
1618 + },
1619 + },
1620 + wantRunning: []string{},
1621 + wantDyncfg: `
1622 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
1623 +{"status":202,"message":""}
1624 +FUNCTION_RESULT_END
1625 +
1626 +CONFIG test:sd:net_listeners:job1 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1627 +
1628 +FUNCTION_RESULT_BEGIN 2-add 202 application/json
1629 +{"status":202,"message":""}
1630 +FUNCTION_RESULT_END
1631 +
1632 +CONFIG test:sd:net_listeners:job2 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1633 +
1634 +FUNCTION_RESULT_BEGIN 3-enable 200 application/json
1635 +{"status":200,"message":""}
1636 +FUNCTION_RESULT_END
1637 +
1638 +CONFIG test:sd:net_listeners:job1 status running
1639 +
1640 +FUNCTION_RESULT_BEGIN 4-enable 200 application/json
1641 +{"status":200,"message":""}
1642 +FUNCTION_RESULT_END
1643 +
1644 +CONFIG test:sd:net_listeners:job2 status running
1645 +
1646 +FUNCTION_RESULT_BEGIN 5-disable 200 application/json
1647 +{"status":200,"message":""}
1648 +FUNCTION_RESULT_END
1649 +
1650 +CONFIG test:sd:net_listeners:job1 status disabled
1651 +
1652 +FUNCTION_RESULT_BEGIN 6-remove 200 application/json
1653 +{"status":200,"message":""}
1654 +FUNCTION_RESULT_END
1655 +
1656 +CONFIG test:sd:net_listeners:job2 delete
1657 +`,
1658 + }
1659 + },
1660 + },
1661 + }
1662 +
1663 + for name, tc := range tests {
1664 + t.Run(name, func(t *testing.T) {
1665 + sim := tc.createSim()
1666 + sim.run(t)
1667 + })
1668 + }
1669 +}
1670 +
1671 +func TestServiceDiscovery_DyncfgPriority(t *testing.T) {
1672 + tests := map[string]struct {
1673 + createSim func() *dyncfgSim
1674 + }{
1675 + "dyncfg add replaces running file config": {
1676 + // File config is running, dyncfg add with same name should replace it
1677 + createSim: func() *dyncfgSim {
1678 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
1679 + payload, _ := json.Marshal(cfg)
1680 +
1681 + return &dyncfgSim{
1682 + do: func(sd *ServiceDiscovery) {
1683 + // Simulate a running file config
1684 + fileCfg := sdConfig{
1685 + "name": "test-job",
1686 + ikeyDiscovererType: DiscovererNetListeners,
1687 + ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
1688 + ikeySource: "/etc/netdata/sd.d/test.conf",
1689 + ikeySourceType: confgroup.TypeUser,
1690 + ikeyStatus: dyncfg.StatusRunning,
1691 + }
1692 + sd.seenConfigs.add(fileCfg)
1693 + sd.exposedConfigs.add(fileCfg)
1694 +
1695 + // Start the pipeline to simulate running state
1696 + pipelineCfg := pipeline.Config{Name: "test-job"}
1697 + _ = sd.mgr.Start(sd.ctx, fileCfg.PipelineKey(), pipelineCfg)
1698 +
1699 + // Dyncfg add with same name - should replace file config
1700 + sendDyncfgCmd(sd, "1-add",
1701 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1702 + payload, "type=dyncfg,user=test")
1703 + },
1704 + wantExposed: []wantExposedConfig{
1705 + {
1706 + discovererType: DiscovererNetListeners,
1707 + name: "test-job",
1708 + sourceType: confgroup.TypeDyncfg, // dyncfg replaces file
1709 + status: dyncfg.StatusAccepted,
1710 + },
1711 + },
1712 + wantRunning: []string{}, // old pipeline stopped
1713 + wantDyncfgFunc: func(t *testing.T, got string) {
1714 + // Should see: create new dyncfg config (no delete - CONFIG create updates existing)
1715 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create accepted job")
1716 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
1717 + },
1718 + }
1719 + },
1720 + },
1721 + "dyncfg add replaces stock file config": {
1722 + // Stock file config exists, dyncfg add should replace it
1723 + createSim: func() *dyncfgSim {
1724 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
1725 + payload, _ := json.Marshal(cfg)
1726 +
1727 + return &dyncfgSim{
1728 + do: func(sd *ServiceDiscovery) {
1729 + // Simulate a stock file config (not running)
1730 + fileCfg := sdConfig{
1731 + "name": "test-job",
1732 + ikeyDiscovererType: DiscovererNetListeners,
1733 + ikeyPipelineKey: "/usr/lib/netdata/conf.d/sd/test.conf",
1734 + ikeySource: "/usr/lib/netdata/conf.d/sd/test.conf",
1735 + ikeySourceType: confgroup.TypeStock,
1736 + ikeyStatus: dyncfg.StatusAccepted,
1737 + }
1738 + sd.seenConfigs.add(fileCfg)
1739 + sd.exposedConfigs.add(fileCfg)
1740 +
1741 + // Dyncfg add with same name - should replace stock config
1742 + sendDyncfgCmd(sd, "1-add",
1743 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1744 + payload, "type=dyncfg,user=test")
1745 + },
1746 + wantExposed: []wantExposedConfig{
1747 + {
1748 + discovererType: DiscovererNetListeners,
1749 + name: "test-job",
1750 + sourceType: confgroup.TypeDyncfg,
1751 + status: dyncfg.StatusAccepted,
1752 + },
1753 + },
1754 + wantDyncfgFunc: func(t *testing.T, got string) {
1755 + // Should see: create new dyncfg config (no delete - CONFIG create updates existing)
1756 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create accepted job")
1757 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
1758 + assert.Contains(t, got, "dyncfg")
1759 + },
1760 + }
1761 + },
1762 + },
1763 + "dyncfg add replaces existing dyncfg config": {
1764 + // Dyncfg config exists, another dyncfg add with same name should replace it (matching jobmgr pattern)
1765 + createSim: func() *dyncfgSim {
1766 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
1767 + payload, _ := json.Marshal(cfg)
1768 +
1769 + return &dyncfgSim{
1770 + do: func(sd *ServiceDiscovery) {
1771 + // Simulate existing dyncfg config
1772 + dyncfgCfg := sdConfig{
1773 + "name": "test-job",
1774 + ikeyDiscovererType: DiscovererNetListeners,
1775 + ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1776 + ikeySource: "type=dyncfg,user=admin",
1777 + ikeySourceType: confgroup.TypeDyncfg,
1778 + ikeyStatus: dyncfg.StatusRunning,
1779 + }
1780 + sd.seenConfigs.add(dyncfgCfg)
1781 + sd.exposedConfigs.add(dyncfgCfg)
1782 +
1783 + // Start the pipeline to simulate running state
1784 + pipelineCfg := pipeline.Config{Name: "test-job"}
1785 + _ = sd.mgr.Start(sd.ctx, dyncfgCfg.PipelineKey(), pipelineCfg)
1786 +
1787 + // Another dyncfg add with same name - should replace (matching jobmgr pattern)
1788 + sendDyncfgCmd(sd, "1-add",
1789 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1790 + payload, "type=dyncfg,user=test")
1791 + },
1792 + wantExposed: []wantExposedConfig{
1793 + {
1794 + discovererType: DiscovererNetListeners,
1795 + name: "test-job",
1796 + sourceType: confgroup.TypeDyncfg,
1797 + status: dyncfg.StatusAccepted, // new config in accepted state
1798 + },
1799 + },
1800 + wantRunning: []string{}, // old pipeline stopped
1801 + wantDyncfgFunc: func(t *testing.T, got string) {
1802 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-add 202 application/json")
1803 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create accepted job")
1804 + },
1805 + }
1806 + },
1807 + },
1808 + }
1809 +
1810 + for name, tc := range tests {
1811 + t.Run(name, func(t *testing.T) {
1812 + sim := tc.createSim()
1813 + sim.run(t)
1814 + })
1815 + }
1816 +}
1817 +
1818 +func TestServiceDiscovery_DyncfgUpdateSameConfig(t *testing.T) {
1819 + tests := map[string]struct {
1820 + createSim func() *dyncfgSim
1821 + }{
1822 + "update running pipeline with same config skips restart": {
1823 + createSim: func() *dyncfgSim {
1824 + cfg := newTestNetListenersConfig("test-job", confopt.LongDuration(5*time.Second), 0, defaultTestServices())
1825 + payload, _ := json.Marshal(cfg)
1826 +
1827 + return &dyncfgSim{
1828 + do: func(sd *ServiceDiscovery) {
1829 + // Add
1830 + sendDyncfgCmd(sd, "1-add",
1831 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
1832 + payload, "type=dyncfg,user=test")
1833 +
1834 + // Enable
1835 + sendDyncfgCmd(sd, "2-enable",
1836 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
1837 + nil, "")
1838 +
1839 + // Update with exact same config (should return 200 without restart)
1840 + sendDyncfgCmd(sd, "3-update",
1841 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
1842 + payload, "type=dyncfg,user=test")
1843 + },
1844 + wantExposed: []wantExposedConfig{
1845 + {
1846 + discovererType: DiscovererNetListeners,
1847 + name: "test-job",
1848 + sourceType: "dyncfg",
1849 + status: dyncfg.StatusRunning,
1850 + },
1851 + },
1852 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
1853 + wantDyncfg: `
1854 +FUNCTION_RESULT_BEGIN 1-add 202 application/json
1855 +{"status":202,"message":""}
1856 +FUNCTION_RESULT_END
1857 +
1858 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1859 +
1860 +FUNCTION_RESULT_BEGIN 2-enable 200 application/json
1861 +{"status":200,"message":""}
1862 +FUNCTION_RESULT_END
1863 +
1864 +CONFIG test:sd:net_listeners:test-job status running
1865 +
1866 +FUNCTION_RESULT_BEGIN 3-update 200 application/json
1867 +{"status":200,"message":""}
1868 +FUNCTION_RESULT_END
1869 +
1870 +CONFIG test:sd:net_listeners:test-job status running
1871 +`,
1872 + }
1873 + },
1874 + },
1875 + }
1876 +
1877 + for name, tc := range tests {
1878 + t.Run(name, func(t *testing.T) {
1879 + sim := tc.createSim()
1880 + sim.run(t)
1881 + })
1882 + }
1883 +}
1884 +
1885 +func TestServiceDiscovery_DyncfgUpdateFailedState(t *testing.T) {
1886 + tests := map[string]struct {
1887 + createSim func() *dyncfgSim
1888 + }{
1889 + "update config in failed state restarts pipeline": {
1890 + createSim: func() *dyncfgSim {
1891 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
1892 + updatedPayload, _ := json.Marshal(updatedCfg)
1893 +
1894 + return &dyncfgSim{
1895 + do: func(sd *ServiceDiscovery) {
1896 + // Manually add a failed config
1897 + failedCfg := sdConfig{
1898 + "name": "test-job",
1899 + ikeyDiscovererType: DiscovererNetListeners,
1900 + ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1901 + ikeySource: "type=dyncfg,user=test",
1902 + ikeySourceType: confgroup.TypeDyncfg,
1903 + ikeyStatus: dyncfg.StatusFailed,
1904 + }
1905 + sd.seenConfigs.add(failedCfg)
1906 + sd.exposedConfigs.add(failedCfg)
1907 +
1908 + // Update should restart the pipeline
1909 + sendDyncfgCmd(sd, "1-update",
1910 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
1911 + updatedPayload, "type=dyncfg,user=test")
1912 + },
1913 + wantExposed: []wantExposedConfig{
1914 + {
1915 + discovererType: DiscovererNetListeners,
1916 + name: "test-job",
1917 + sourceType: "dyncfg",
1918 + status: dyncfg.StatusRunning,
1919 + },
1920 + },
1921 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
1922 + wantDyncfgFunc: func(t *testing.T, got string) {
1923 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-update 200 application/json")
1924 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job status running")
1925 + },
1926 + }
1927 + },
1928 + },
1929 + }
1930 +
1931 + for name, tc := range tests {
1932 + t.Run(name, func(t *testing.T) {
1933 + sim := tc.createSim()
1934 + sim.run(t)
1935 + })
1936 + }
1937 +}
1938 +
1939 +func TestServiceDiscovery_DyncfgEnableFromFailed(t *testing.T) {
1940 + tests := map[string]struct {
1941 + createSim func() *dyncfgSim
1942 + }{
1943 + "enable config from failed state starts pipeline": {
1944 + createSim: func() *dyncfgSim {
1945 + return &dyncfgSim{
1946 + do: func(sd *ServiceDiscovery) {
1947 + // Manually add a failed config with valid pipeline config data
1948 + failedCfg := sdConfig{
1949 + "name": "test-job",
1950 + ikeyDiscovererType: DiscovererNetListeners,
1951 + ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1952 + ikeySource: "type=dyncfg,user=test",
1953 + ikeySourceType: confgroup.TypeDyncfg,
1954 + ikeyStatus: dyncfg.StatusFailed,
1955 + "discoverer": map[string]any{
1956 + "net_listeners": map[string]any{},
1957 + },
1958 + "services": []any{
1959 + map[string]any{"id": "test-rule", "match": "true"},
1960 + },
1961 + }
1962 + sd.seenConfigs.add(failedCfg)
1963 + sd.exposedConfigs.add(failedCfg)
1964 +
1965 + // Enable should start the pipeline
1966 + sendDyncfgCmd(sd, "1-enable",
1967 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
1968 + nil, "")
1969 + },
1970 + wantExposed: []wantExposedConfig{
1971 + {
1972 + discovererType: DiscovererNetListeners,
1973 + name: "test-job",
1974 + sourceType: "dyncfg",
1975 + status: dyncfg.StatusRunning,
1976 + },
1977 + },
1978 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
1979 + wantDyncfgFunc: func(t *testing.T, got string) {
1980 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-enable 200 application/json")
1981 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job status running")
1982 + },
1983 + }
1984 + },
1985 + },
1986 + }
1987 +
1988 + for name, tc := range tests {
1989 + t.Run(name, func(t *testing.T) {
1990 + sim := tc.createSim()
1991 + sim.run(t)
1992 + })
1993 + }
1994 +}
1995 +
1996 +func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
1997 + tests := map[string]struct {
1998 + createSim func() *dyncfgSim
1999 + }{
2000 + "update file config converts to dyncfg": {
2001 + createSim: func() *dyncfgSim {
2002 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
2003 + updatedPayload, _ := json.Marshal(updatedCfg)
2004 +
2005 + return &dyncfgSim{
2006 + do: func(sd *ServiceDiscovery) {
2007 + // Simulate running file config
2008 + fileCfg := sdConfig{
2009 + "name": "test-job",
2010 + ikeyDiscovererType: DiscovererNetListeners,
2011 + ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2012 + ikeySource: "/etc/netdata/sd.d/test.conf",
2013 + ikeySourceType: confgroup.TypeUser,
2014 + ikeyStatus: dyncfg.StatusRunning,
2015 + "discoverer": map[string]any{
2016 + "net_listeners": map[string]any{},
2017 + },
2018 + "services": []any{
2019 + map[string]any{"id": "test-rule", "match": "true"},
2020 + },
2021 + }
2022 + sd.seenConfigs.add(fileCfg)
2023 + sd.exposedConfigs.add(fileCfg)
2024 +
2025 + // Start the file pipeline
2026 + pipelineCfg := pipeline.Config{Name: "test-job"}
2027 + _ = sd.mgr.Start(sd.ctx, fileCfg.PipelineKey(), pipelineCfg)
2028 +
2029 + // Update via dyncfg - should convert to dyncfg source
2030 + sendDyncfgCmd(sd, "1-update",
2031 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
2032 + updatedPayload, "type=dyncfg,user=admin")
2033 + },
2034 + wantExposed: []wantExposedConfig{
2035 + {
2036 + discovererType: DiscovererNetListeners,
2037 + name: "test-job",
2038 + sourceType: confgroup.TypeDyncfg, // Converted to dyncfg!
2039 + status: dyncfg.StatusRunning,
2040 + },
2041 + },
2042 + wantRunning: []string{"dyncfg:net_listeners:test-job"}, // New pipeline key
2043 + wantDyncfgFunc: func(t *testing.T, got string) {
2044 + // Should see: delete old job, create new dyncfg job
2045 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job delete")
2046 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create running job")
2047 + assert.Contains(t, got, "dyncfg") // New source type
2048 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-update 200 application/json")
2049 + },
2050 + }
2051 + },
2052 + },
2053 + "update disabled file config converts to dyncfg without starting": {
2054 + createSim: func() *dyncfgSim {
2055 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
2056 + updatedPayload, _ := json.Marshal(updatedCfg)
2057 +
2058 + return &dyncfgSim{
2059 + do: func(sd *ServiceDiscovery) {
2060 + // Simulate disabled file config
2061 + fileCfg := sdConfig{
2062 + "name": "test-job",
2063 + ikeyDiscovererType: DiscovererNetListeners,
2064 + ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2065 + ikeySource: "/etc/netdata/sd.d/test.conf",
2066 + ikeySourceType: confgroup.TypeUser,
2067 + ikeyStatus: dyncfg.StatusDisabled,
2068 + "discoverer": map[string]any{
2069 + "net_listeners": map[string]any{},
2070 + },
2071 + "services": []any{
2072 + map[string]any{"id": "test-rule", "match": "true"},
2073 + },
2074 + }
2075 + sd.seenConfigs.add(fileCfg)
2076 + sd.exposedConfigs.add(fileCfg)
2077 +
2078 + // Update via dyncfg - should convert but stay disabled
2079 + sendDyncfgCmd(sd, "1-update",
2080 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
2081 + updatedPayload, "type=dyncfg,user=admin")
2082 + },
2083 + wantExposed: []wantExposedConfig{
2084 + {
2085 + discovererType: DiscovererNetListeners,
2086 + name: "test-job",
2087 + sourceType: confgroup.TypeDyncfg,
2088 + status: dyncfg.StatusDisabled, // Stays disabled
2089 + },
2090 + },
2091 + wantRunning: []string{}, // Not running
2092 + wantDyncfgFunc: func(t *testing.T, got string) {
2093 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job delete")
2094 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create disabled job")
2095 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-update 200 application/json")
2096 + },
2097 + }
2098 + },
2099 + },
2100 + }
2101 +
2102 + for name, tc := range tests {
2103 + t.Run(name, func(t *testing.T) {
2104 + sim := tc.createSim()
2105 + sim.run(t)
2106 + })
2107 + }
2108 +}
2109 +
2110 +func TestServiceDiscovery_DyncfgRestartErrorHandling(t *testing.T) {
2111 + tests := map[string]struct {
2112 + createSim func() *dyncfgSim
2113 + }{
2114 + "restart with invalid config keeps old pipeline running": {
2115 + createSim: func() *dyncfgSim {
2116 + cfg := newTestNetListenersConfig("test-job", 0, 0, defaultTestServices())
2117 + payload, _ := json.Marshal(cfg)
2118 +
2119 + return &dyncfgSim{
2120 + do: func(sd *ServiceDiscovery) {
2121 + // Override newPipeline to fail on second call
2122 + callCount := 0
2123 + sd.newPipeline = func(cfg pipeline.Config) (sdPipeline, error) {
2124 + callCount++
2125 + if callCount > 1 {
2126 + return nil, errors.New("simulated pipeline creation failure")
2127 + }
2128 + return newTestPipeline(cfg.Name), nil
2129 + }
2130 + // Also update mgr's newPipeline
2131 + sd.mgr.newPipeline = sd.newPipeline
2132 +
2133 + // Add and enable
2134 + sendDyncfgCmd(sd, "1-add",
2135 + []string{sd.dyncfgTemplateID(DiscovererNetListeners), "add", "test-job"},
2136 + payload, "type=dyncfg,user=test")
2137 +
2138 + sendDyncfgCmd(sd, "2-enable",
2139 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "enable"},
2140 + nil, "")
2141 +
2142 + // Update - pipeline creation will fail
2143 + updatedCfg := newTestNetListenersConfig("test-job", confopt.LongDuration(10*time.Second), 0, defaultTestServices())
2144 + updatedPayload, _ := json.Marshal(updatedCfg)
2145 +
2146 + sendDyncfgCmd(sd, "3-update",
2147 + []string{sd.dyncfgJobID(DiscovererNetListeners, "test-job"), "update"},
2148 + updatedPayload, "type=dyncfg,user=test")
2149 + },
2150 + wantExposed: []wantExposedConfig{
2151 + {
2152 + discovererType: DiscovererNetListeners,
2153 + name: "test-job",
2154 + sourceType: "dyncfg",
2155 + status: dyncfg.StatusFailed,
2156 + },
2157 + },
2158 + // NOTE: When Restart fails validation (newPipeline fails), the old pipeline
2159 + // keeps running. This is the intended Restart behavior - validate before stopping.
2160 + // The status shows Failed but old pipeline continues collecting data.
2161 + wantRunning: []string{"dyncfg:net_listeners:test-job"},
2162 + wantDyncfgFunc: func(t *testing.T, got string) {
2163 + assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 3-update 200 application/json")
2164 + assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job status failed")
2165 + },
2166 + }
2167 + },
2168 + },
2169 + }
2170 +
2171 + for name, tc := range tests {
2172 + t.Run(name, func(t *testing.T) {
2173 + sim := tc.createSim()
2174 + sim.run(t)
2175 + })
2176 + }
2177 +}
2178 +
2179 +func TestServiceDiscovery_DyncfgFileRemovalWithDyncfgOverride(t *testing.T) {
2180 + tests := map[string]struct {
2181 + createSim func() *dyncfgSim
2182 + }{
2183 + "file config removal does not affect dyncfg override": {
2184 + createSim: func() *dyncfgSim {
2185 + return &dyncfgSim{
2186 + do: func(sd *ServiceDiscovery) {
2187 + // Add file config to seenConfigs (simulating it was seen from file)
2188 + fileCfg := sdConfig{
2189 + "name": "test-job",
2190 + ikeyDiscovererType: DiscovererNetListeners,
2191 + ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2192 + ikeySource: "/etc/netdata/sd.d/test.conf",
2193 + ikeySourceType: confgroup.TypeUser,
2194 + ikeyStatus: dyncfg.StatusAccepted,
2195 + }
2196 + sd.seenConfigs.add(fileCfg)
2197 +
2198 + // Add dyncfg override (higher priority) to both caches
2199 + dyncfgCfg := sdConfig{
2200 + "name": "test-job",
2201 + ikeyDiscovererType: DiscovererNetListeners,
2202 + ikeyPipelineKey: "dyncfg:net_listeners:test-job",
2203 + ikeySource: "type=dyncfg,user=test",
2204 + ikeySourceType: confgroup.TypeDyncfg,
2205 + ikeyStatus: dyncfg.StatusRunning,
2206 + }
2207 + sd.seenConfigs.add(dyncfgCfg)
2208 + sd.exposedConfigs.add(dyncfgCfg)
2209 +
2210 + // Start the dyncfg pipeline
2211 + pipelineCfg := pipeline.Config{Name: "test-job"}
2212 + _ = sd.mgr.Start(sd.ctx, dyncfgCfg.PipelineKey(), pipelineCfg)
2213 +
2214 + // Simulate file removal by calling removePipeline
2215 + sd.removePipeline(confFile{source: "/etc/netdata/sd.d/test.conf"})
2216 + },
2217 + wantExposed: []wantExposedConfig{
2218 + {
2219 + discovererType: DiscovererNetListeners,
2220 + name: "test-job",
2221 + sourceType: confgroup.TypeDyncfg, // Dyncfg still exposed
2222 + status: dyncfg.StatusRunning,
2223 + },
2224 + },
2225 + wantRunning: []string{"dyncfg:net_listeners:test-job"}, // Dyncfg pipeline still running
2226 + }
2227 + },
2228 + },
2229 + }
2230 +
2231 + for name, tc := range tests {
2232 + t.Run(name, func(t *testing.T) {
2233 + sim := tc.createSim()
2234 + sim.run(t)
2235 + })
2236 + }
2237 +}
2238 +
2239 +// testPipeline is a simple pipeline for testing that just waits for cancellation.
2240 +type testPipeline struct {
2241 + name string
2242 +}
2243 +
2244 +func newTestPipeline(name string) *testPipeline {
2245 + return &testPipeline{name: name}
2246 +}
2247 +
2248 +func (p *testPipeline) Run(ctx context.Context, out chan<- []*confgroup.Group) {
2249 + <-ctx.Done()
2250 +}
src/go/plugin/go.d/agent/discovery/sd/model/funcs.go new
+40
@@ -0,0 +1,40 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package model
4 +
5 +import (
6 + "context"
7 +
8 + "github.com/gohugoio/hashstructure"
9 +)
10 +
11 +// CalcHash calculates a hash for any object using hashstructure.
12 +// Used by discoverers to generate unique hashes for targets.
13 +func CalcHash(obj any) (uint64, error) {
14 + return hashstructure.Hash(obj, nil)
15 +}
16 +
17 +// MapAny converts a map[string]string to map[string]any.
18 +// Used by discoverers to convert labels/annotations for template evaluation.
19 +func MapAny(src map[string]string) map[string]any {
20 + if src == nil {
21 + return nil
22 + }
23 + m := make(map[string]any, len(src))
24 + for k, v := range src {
25 + m[k] = v
26 + }
27 + return m
28 +}
29 +
30 +// SendTargetGroup sends a target group to the channel with context cancellation support.
31 +// If tgg is nil, nothing is sent.
32 +func SendTargetGroup(ctx context.Context, in chan<- []TargetGroup, tgg TargetGroup) {
33 + if tgg == nil {
34 + return
35 + }
36 + select {
37 + case <-ctx.Done():
38 + case in <- []TargetGroup{tgg}:
39 + }
40 +}
src/go/plugin/go.d/agent/discovery/sd/pipeline/classify.go deleted
-132
@@ -1,132 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package pipeline
4 -
5 -import (
6 - "bytes"
7 - "fmt"
8 - "strings"
9 - "text/template"
10 -
11 - "github.com/netdata/netdata/go/plugins/logger"
12 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
13 -)
14 -
15 -func newTargetClassificator(cfg []ClassifyRuleConfig) (*targetClassificator, error) {
16 - rules, err := newClassifyRules(cfg)
17 - if err != nil {
18 - return nil, err
19 - }
20 -
21 - c := &targetClassificator{
22 - rules: rules,
23 - buf: bytes.Buffer{},
24 - }
25 -
26 - return c, nil
27 -}
28 -
29 -type (
30 - targetClassificator struct {
31 - *logger.Logger
32 - rules []*classifyRule
33 - buf bytes.Buffer
34 - }
35 -
36 - classifyRule struct {
37 - name string
38 - sr selector
39 - tags model.Tags
40 - match []*classifyRuleMatch
41 - }
42 - classifyRuleMatch struct {
43 - tags model.Tags
44 - expr *template.Template
45 - }
46 -)
47 -
48 -func (c *targetClassificator) classify(tgt model.Target) model.Tags {
49 - tgtTags := tgt.Tags().Clone()
50 - var tags model.Tags
51 -
52 - for i, rule := range c.rules {
53 - if !rule.sr.matches(tgtTags) {
54 - continue
55 - }
56 -
57 - for j, match := range rule.match {
58 - c.buf.Reset()
59 -
60 - if err := match.expr.Execute(&c.buf, tgt); err != nil {
61 - c.Warningf("failed to execute classify rule[%d]->match[%d]->expr on target '%s'", i+1, j+1, tgt.TUID())
62 - continue
63 - }
64 - if strings.TrimSpace(c.buf.String()) != "true" {
65 - continue
66 - }
67 -
68 - if tags == nil {
69 - tags = model.NewTags()
70 - }
71 -
72 - tags.Add(rule.tags)
73 - tags.Add(match.tags)
74 - tgtTags.Merge(tags)
75 - }
76 - }
77 -
78 - return tags
79 -}
80 -
81 -func newClassifyRules(cfg []ClassifyRuleConfig) ([]*classifyRule, error) {
82 - var rules []*classifyRule
83 -
84 - fmap := newFuncMap()
85 -
86 - for i, ruleCfg := range cfg {
87 - i++
88 - rule := classifyRule{name: ruleCfg.Name}
89 -
90 - sr, err := parseSelector(ruleCfg.Selector)
91 - if err != nil {
92 - return nil, fmt.Errorf("rule '%d': %v", i, err)
93 - }
94 - rule.sr = sr
95 -
96 - tags, err := model.ParseTags(ruleCfg.Tags)
97 - if err != nil {
98 - return nil, fmt.Errorf("rule '%d': %v", i, err)
99 - }
100 - rule.tags = tags
101 -
102 - for j, matchCfg := range ruleCfg.Match {
103 - j++
104 - var match classifyRuleMatch
105 -
106 - tags, err := model.ParseTags(matchCfg.Tags)
107 - if err != nil {
108 - return nil, fmt.Errorf("rule '%d/%d': %v", i, j, err)
109 - }
110 - match.tags = tags
111 -
112 - tmpl, err := parseTemplate(matchCfg.Expr, fmap)
113 - if err != nil {
114 - return nil, fmt.Errorf("rule '%d/%d': %v", i, j, err)
115 - }
116 - match.expr = tmpl
117 -
118 - rule.match = append(rule.match, &match)
119 - }
120 -
121 - rules = append(rules, &rule)
122 - }
123 -
124 - return rules, nil
125 -}
126 -
127 -func parseTemplate(s string, fmap template.FuncMap) (*template.Template, error) {
128 - return template.New("root").
129 - Option("missingkey=error").
130 - Funcs(fmap).
131 - Parse(s)
132 -}
src/go/plugin/go.d/agent/discovery/sd/pipeline/classify_test.go deleted
-83
@@ -1,83 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package pipeline
4 -
5 -import (
6 - "testing"
7 -
8 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
9 -
10 - "github.com/stretchr/testify/assert"
11 - "github.com/stretchr/testify/require"
12 - "gopkg.in/yaml.v2"
13 -)
14 -
15 -func TestTargetClassificator_classify(t *testing.T) {
16 - config := `
17 -- selector: "rule0"
18 - tags: "skip"
19 - match:
20 - - tags: "skip"
21 - expr: '{{ glob .Name "*" }}'
22 -- selector: "!skip rule1"
23 - tags: "foo1"
24 - match:
25 - - tags: "bar1"
26 - expr: '{{ glob .Name "mock*1*" }}'
27 - - tags: "bar2"
28 - expr: '{{ glob .Name "mock*2*" }}'
29 -- selector: "!skip rule2"
30 - tags: "foo2"
31 - match:
32 - - tags: "bar3"
33 - expr: '{{ glob .Name "mock*3*" }}'
34 - - tags: "bar4"
35 - expr: '{{ glob .Name "mock*4*" }}'
36 -- selector: "rule3"
37 - tags: "-skip foo3"
38 - match:
39 - - tags: "bar5"
40 - expr: '{{ glob .Name "mock*5*" }}'
41 - - tags: "bar6"
42 - expr: '{{ glob .Name "mock*6*" }}'
43 -`
44 - tests := map[string]struct {
45 - target model.Target
46 - wantTags model.Tags
47 - }{
48 - "no rules match": {
49 - target: newMockTarget("mock1"),
50 - wantTags: nil,
51 - },
52 - "one rule one match": {
53 - target: newMockTarget("mock4", "rule2"),
54 - wantTags: mustParseTags("foo2 bar4"),
55 - },
56 - "one rule two match": {
57 - target: newMockTarget("mock56", "rule3"),
58 - wantTags: mustParseTags("-skip foo3 bar5 bar6"),
59 - },
60 - "all rules all matches": {
61 - target: newMockTarget("mock123456", "rule1 rule2 rule3"),
62 - wantTags: mustParseTags("-skip foo1 foo2 foo3 bar1 bar2 bar3 bar4 bar5 bar6"),
63 - },
64 - "applying labels after every rule": {
65 - target: newMockTarget("mock123456", "rule0 rule1 rule2 rule3"),
66 - wantTags: mustParseTags("-skip foo3 bar5 bar6"),
67 - },
68 - }
69 -
70 - for name, test := range tests {
71 - t.Run(name, func(t *testing.T) {
72 - var cfg []ClassifyRuleConfig
73 -
74 - err := yaml.Unmarshal([]byte(config), &cfg)
75 - require.NoError(t, err, "yaml unmarshalling of config")
76 -
77 - clr, err := newTargetClassificator(cfg)
78 - require.NoError(t, err, "targetClassificator creation")
79 -
80 - assert.Equal(t, test.wantTags, clr.classify(test.target))
81 - })
82 - }
83 -}
src/go/plugin/go.d/agent/discovery/sd/pipeline/compose.go deleted
-125
@@ -1,125 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package pipeline
4 -
5 -import (
6 - "bytes"
7 - "fmt"
8 - "text/template"
9 -
10 - "github.com/netdata/netdata/go/plugins/logger"
11 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
12 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
13 -)
14 -
15 -func newConfigComposer(cfg []ComposeRuleConfig) (*configComposer, error) {
16 - rules, err := newComposeRules(cfg)
17 - if err != nil {
18 - return nil, err
19 - }
20 -
21 - c := &configComposer{
22 - rules: rules,
23 - buf: bytes.Buffer{},
24 - }
25 -
26 - return c, nil
27 -}
28 -
29 -type (
30 - configComposer struct {
31 - *logger.Logger
32 - rules []*composeRule
33 - buf bytes.Buffer
34 - }
35 -
36 - composeRule struct {
37 - name string
38 - sr selector
39 - conf []*composeRuleConf
40 - }
41 - composeRuleConf struct {
42 - sr selector
43 - tmpl *template.Template
44 - }
45 -)
46 -
47 -func (c *configComposer) compose(tgt model.Target) []confgroup.Config {
48 - var configs []confgroup.Config
49 -
50 - for i, rule := range c.rules {
51 - if !rule.sr.matches(tgt.Tags()) {
52 - continue
53 - }
54 -
55 - for j, conf := range rule.conf {
56 - if !conf.sr.matches(tgt.Tags()) {
57 - continue
58 - }
59 -
60 - c.buf.Reset()
61 -
62 - if err := conf.tmpl.Execute(&c.buf, tgt); err != nil {
63 - c.Warningf("failed to execute rule[%d]->config[%d]->template on target '%s': %v",
64 - i+1, j+1, tgt.TUID(), err)
65 - continue
66 - }
67 - if c.buf.Len() == 0 {
68 - continue
69 - }
70 -
71 - cfgs, err := parseConfigTemplateData(c.buf.Bytes())
72 - if err != nil {
73 - c.Warningf("failed to parse template data: %v", err)
74 - continue
75 - }
76 -
77 - configs = append(configs, cfgs...)
78 - }
79 - }
80 -
81 - if len(configs) > 0 {
82 - c.Debugf("created %d config(s) for target '%s'", len(configs), tgt.TUID())
83 - }
84 - return configs
85 -}
86 -
87 -func newComposeRules(cfg []ComposeRuleConfig) ([]*composeRule, error) {
88 - var rules []*composeRule
89 -
90 - fmap := newFuncMap()
91 -
92 - for i, ruleCfg := range cfg {
93 - i++
94 - rule := composeRule{name: ruleCfg.Name}
95 -
96 - sr, err := parseSelector(ruleCfg.Selector)
97 - if err != nil {
98 - return nil, fmt.Errorf("rule '%d': %v", i, err)
99 - }
100 - rule.sr = sr
101 -
102 - for j, confCfg := range ruleCfg.Config {
103 - j++
104 - var conf composeRuleConf
105 -
106 - sr, err := parseSelector(confCfg.Selector)
107 - if err != nil {
108 - return nil, fmt.Errorf("rule '%d/%d': %v", i, j, err)
109 - }
110 - conf.sr = sr
111 -
112 - tmpl, err := parseTemplate(confCfg.Template, fmap)
113 - if err != nil {
114 - return nil, fmt.Errorf("rule '%d/%d': %v", i, j, err)
115 - }
116 - conf.tmpl = tmpl
117 -
118 - rule.conf = append(rule.conf, &conf)
119 - }
120 -
121 - rules = append(rules, &rule)
122 - }
123 -
124 - return rules, nil
125 -}
src/go/plugin/go.d/agent/discovery/sd/pipeline/compose_test.go deleted
-92
@@ -1,92 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package pipeline
4 -
5 -import (
6 - "testing"
7 -
8 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
9 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
10 -
11 - "github.com/stretchr/testify/assert"
12 - "github.com/stretchr/testify/require"
13 - "gopkg.in/yaml.v2"
14 -)
15 -
16 -func TestConfigComposer_compose(t *testing.T) {
17 - config := `
18 -- selector: "rule1"
19 - config:
20 - - selector: "bar1"
21 - template: |
22 - name: {{ .Name }}-1
23 - - selector: "bar2"
24 - template: |
25 - name: {{ .Name }}-2
26 -- selector: "rule2"
27 - config:
28 - - selector: "bar3"
29 - template: |
30 - name: {{ .Name }}-3
31 - - selector: "bar4"
32 - template: |
33 - name: {{ .Name }}-4
34 -- selector: "rule3"
35 - config:
36 - - selector: "bar5"
37 - template: |
38 - name: {{ .Name }}-5
39 - - selector: "bar6"
40 - template: |
41 - - name: {{ .Name }}-6
42 - - name: {{ .Name }}-7
43 -`
44 - tests := map[string]struct {
45 - target model.Target
46 - wantConfigs []confgroup.Config
47 - }{
48 - "no rules matches": {
49 - target: newMockTarget("mock"),
50 - wantConfigs: nil,
51 - },
52 - "one rule one config": {
53 - target: newMockTarget("mock", "rule1 bar1"),
54 - wantConfigs: []confgroup.Config{
55 - {"name": "mock-1"},
56 - },
57 - },
58 - "one rule two config": {
59 - target: newMockTarget("mock", "rule2 bar3 bar4"),
60 - wantConfigs: []confgroup.Config{
61 - {"name": "mock-3"},
62 - {"name": "mock-4"},
63 - },
64 - },
65 - "all rules all configs": {
66 - target: newMockTarget("mock", "rule1 bar1 bar2 rule2 bar3 bar4 rule3 bar5 bar6"),
67 - wantConfigs: []confgroup.Config{
68 - {"name": "mock-1"},
69 - {"name": "mock-2"},
70 - {"name": "mock-3"},
71 - {"name": "mock-4"},
72 - {"name": "mock-5"},
73 - {"name": "mock-6"},
74 - {"name": "mock-7"},
75 - },
76 - },
77 - }
78 -
79 - for name, test := range tests {
80 - t.Run(name, func(t *testing.T) {
81 - var cfg []ComposeRuleConfig
82 -
83 - err := yaml.Unmarshal([]byte(config), &cfg)
84 - require.NoErrorf(t, err, "yaml unmarshalling of config")
85 -
86 - cmr, err := newConfigComposer(cfg)
87 - require.NoErrorf(t, err, "configComposer creation")
88 -
89 - assert.Equal(t, test.wantConfigs, cmr.compose(test.target))
90 - })
91 - }
92 -}
src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go
+155 -100
@@ -16,23 +16,155 @@ import (
16 )
17
18 type Config struct {
19 - Source string `yaml:"-"`
20 - ConfigDefaults confgroup.Registry `yaml:"-"`
19 + Source string `yaml:"-" json:"-"`
20 + ConfigDefaults confgroup.Registry `yaml:"-" json:"-"`
21
22 - Disabled bool `yaml:"disabled"`
23 - Name string `yaml:"name"`
22 + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
23 + Name string `yaml:"name" json:"name"`
24 +
25 + // New format: single discoverer struct
26 + Discoverer DiscovererConfig `yaml:"discoverer,omitempty" json:"discoverer,omitempty"`
27 +
28 + // New single-step format for service rules:
29 + Services []ServiceRuleConfig `yaml:"services,omitempty" json:"services,omitempty"`
30 +
31 + // Legacy formats (converted during unmarshal, excluded from JSON):
32 + LegacyDiscover []LegacyDiscoveryConfig `yaml:"discover,omitempty" json:"-"`
33 + LegacyClassify []ClassifyRuleConfig `yaml:"classify,omitempty" json:"-"`
34 + LegacyCompose []ComposeRuleConfig `yaml:"compose,omitempty" json:"-"`
35 +}
36 +
37 +// DiscovererConfig holds the configuration for a single discoverer type.
38 +// Only one of the fields should be set.
39 +type DiscovererConfig struct {
40 + K8s []k8ssd.Config `yaml:"k8s,omitempty" json:"k8s,omitempty"`
41 + Docker *dockersd.Config `yaml:"docker,omitempty" json:"docker,omitempty"`
42 + NetListeners *netlistensd.Config `yaml:"net_listeners,omitempty" json:"net_listeners,omitempty"`
43 + SNMP *snmpsd.Config `yaml:"snmp,omitempty" json:"snmp,omitempty"`
44 +}
45 +
46 +// Type returns the discoverer type name, or empty string if none set.
47 +func (d DiscovererConfig) Type() string {
48 + switch {
49 + case d.NetListeners != nil:
50 + return "net_listeners"
51 + case d.Docker != nil:
52 + return "docker"
53 + case len(d.K8s) > 0:
54 + return "k8s"
55 + case d.SNMP != nil:
56 + return "snmp"
57 + default:
58 + return ""
59 + }
60 +}
61
25 - Discover []DiscoveryConfig `yaml:"discover"`
62 +// Empty returns true if no discoverer is configured.
63 +func (d DiscovererConfig) Empty() bool {
64 + return d.Type() == ""
65 +}
66 +
67 +// count returns the number of discoverer types configured.
68 +func (d DiscovererConfig) count() int {
69 + n := 0
70 + if d.NetListeners != nil {
71 + n++
72 + }
73 + if d.Docker != nil {
74 + n++
75 + }
76 + if len(d.K8s) > 0 {
77 + n++
78 + }
79 + if d.SNMP != nil {
80 + n++
81 + }
82 + return n
83 +}
84
27 - // New single-step format:
28 - Services []ServiceRuleConfig `yaml:"services"`
85 +// CleanName returns the name sanitized for use in dyncfg IDs.
86 +// Replaces spaces and colons to avoid parsing issues.
87 +func (c Config) CleanName() string {
88 + name := strings.ReplaceAll(c.Name, " ", "_")
89 + name = strings.ReplaceAll(name, ":", "_")
90 + return name
91 +}
92
30 - // Legacy two-step:
31 - Classify []ClassifyRuleConfig `yaml:"classify"`
32 - Compose []ComposeRuleConfig `yaml:"compose"`
93 +// UnmarshalYAML implements yaml.Unmarshaler.
94 +// It converts legacy formats to the canonical format:
95 +// - discover[] → discoverer{}
96 +// - classify/compose → services[]
97 +func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
98 + type plain Config // avoid recursion
99 + if err := unmarshal((*plain)(c)); err != nil {
100 + return err
101 + }
102 +
103 + // Convert legacy discover[] to new discoverer{} format
104 + if len(c.LegacyDiscover) > 0 && c.Discoverer.Empty() {
105 + c.convertLegacyDiscover()
106 + }
107 +
108 + // Convert legacy classify/compose to canonical services format
109 + if len(c.Services) == 0 && (len(c.LegacyClassify) > 0 || len(c.LegacyCompose) > 0) {
110 + services, err := ConvertOldToServices(c.LegacyClassify, c.LegacyCompose)
111 + if err != nil {
112 + return fmt.Errorf("failed to convert legacy config: %w", err)
113 + }
114 + c.Services = services
115 + }
116 +
117 + // Clear legacy fields - config is now in canonical form
118 + c.LegacyDiscover = nil
119 + c.LegacyClassify = nil
120 + c.LegacyCompose = nil
121 +
122 + return nil
123 +}
124 +
125 +// convertLegacyDiscover converts legacy discover[] array to new discoverer{} struct.
126 +// Only the first discoverer of each type is used.
127 +func (c *Config) convertLegacyDiscover() {
128 + for _, d := range c.LegacyDiscover {
129 + switch d.Discoverer {
130 + case "net_listeners":
131 + if c.Discoverer.NetListeners == nil {
132 + c.Discoverer.NetListeners = &d.NetListeners
133 + }
134 + case "docker":
135 + if c.Discoverer.Docker == nil {
136 + c.Discoverer.Docker = &d.Docker
137 + }
138 + case "k8s":
139 + c.Discoverer.K8s = append(c.Discoverer.K8s, d.K8s...)
140 + case "snmp":
141 + if c.Discoverer.SNMP == nil {
142 + c.Discoverer.SNMP = &d.SNMP
143 + }
144 + }
145 + }
146 +}
147 +
148 +// MarshalYAML implements yaml.Marshaler.
149 +// It only marshals the canonical format, not legacy fields.
150 +func (c Config) MarshalYAML() (any, error) {
151 + type output struct {
152 + Disabled bool `yaml:"disabled,omitempty"`
153 + Name string `yaml:"name"`
154 + Discoverer DiscovererConfig `yaml:"discoverer,omitempty"`
155 + Services []ServiceRuleConfig `yaml:"services,omitempty"`
156 + }
157 + return output{
158 + Disabled: c.Disabled,
159 + Name: c.Name,
160 + Discoverer: c.Discoverer,
161 + Services: c.Services,
162 + }, nil
163 }
164
35 -type DiscoveryConfig struct {
165 +// LegacyDiscoveryConfig is the old discover[] array item format.
166 +// Kept for backwards compatibility during unmarshal.
167 +type LegacyDiscoveryConfig struct {
168 Discoverer string `yaml:"discoverer"`
169 NetListeners netlistensd.Config `yaml:"net_listeners"`
170 Docker dockersd.Config `yaml:"docker"`
@@ -41,9 +173,9 @@ type DiscoveryConfig struct {
173 }
174
175 type ServiceRuleConfig struct {
44 - ID string `yaml:"id"` // mandatory (for logging/diagnostics)
45 - Match string `yaml:"match"` // mandatory
46 - ConfigTemplate string `yaml:"config_template"` // optional (drop if empty)
176 + ID string `yaml:"id" json:"id"` // mandatory (for logging/diagnostics)
177 + Match string `yaml:"match" json:"match"` // mandatory
178 + ConfigTemplate string `yaml:"config_template,omitempty" json:"config_template,omitempty"` // optional (drop if empty)
179 }
180
181 type ClassifyRuleConfig struct {
@@ -65,41 +197,20 @@ type ComposeRuleConfig struct {
197 } `yaml:"config"` // mandatory, at least 1
198 }
199
68 -func validateConfig(cfg Config) error {
200 +// ValidateConfig validates a pipeline configuration.
201 +// Exported for use by dyncfg validation.
202 +func ValidateConfig(cfg Config) error {
203 if cfg.Name == "" {
204 return errors.New("'name' not set")
205 }
72 - if err := validateDiscoveryConfig(cfg.Discover); err != nil {
73 - return fmt.Errorf("discover config: %v", err)
206 + if cfg.Discoverer.Empty() {
207 + return errors.New("no discoverer configured")
208 }
75 -
76 - switch {
77 - case len(cfg.Services) > 0:
78 - if err := validateServicesConfig(cfg.Services); err != nil {
79 - return fmt.Errorf("services rules: %v", err)
80 - }
81 - default:
82 - // Legacy path
83 - if err := validateClassifyConfig(cfg.Classify); err != nil {
84 - return fmt.Errorf("classify rules: %v", err)
85 - }
86 - if err := validateComposeConfig(cfg.Compose); err != nil {
87 - return fmt.Errorf("compose rules: %v", err)
88 - }
89 - }
90 - return nil
91 -}
92 -
93 -func validateDiscoveryConfig(config []DiscoveryConfig) error {
94 - if len(config) == 0 {
95 - return errors.New("no discoverers, must be at least one")
209 + if cfg.Discoverer.count() > 1 {
210 + return errors.New("multiple discoverers configured, only one is allowed")
211 }
97 - for _, cfg := range config {
98 - switch cfg.Discoverer {
99 - case "net_listeners", "docker", "k8s", "snmp":
100 - default:
101 - return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
102 - }
212 + if err := validateServicesConfig(cfg.Services); err != nil {
213 + return fmt.Errorf("services rules: %v", err)
214 }
215 return nil
216 }
@@ -121,62 +232,6 @@ func validateServicesConfig(rules []ServiceRuleConfig) error {
232 return nil
233 }
234
124 -func validateClassifyConfig(rules []ClassifyRuleConfig) error {
125 - if len(rules) == 0 {
126 - return errors.New("empty config, need least 1 rule")
127 - }
128 - for i, rule := range rules {
129 - i++
130 - if rule.Selector == "" {
131 - return fmt.Errorf("'rule[%s][%d]->selector' not set", rule.Name, i)
132 - }
133 - if rule.Tags == "" {
134 - return fmt.Errorf("'rule[%s][%d]->tags' not set", rule.Name, i)
135 - }
136 - if len(rule.Match) == 0 {
137 - return fmt.Errorf("'rule[%s][%d]->match' not set, need at least 1 rule match", rule.Name, i)
138 - }
139 -
140 - for j, match := range rule.Match {
141 - j++
142 - if match.Tags == "" {
143 - return fmt.Errorf("'rule[%s][%d]->match[%d]->tags' not set", rule.Name, i, j)
144 - }
145 - if match.Expr == "" {
146 - return fmt.Errorf("'rule[%s][%d]->match[%d]->expr' not set", rule.Name, i, j)
147 - }
148 - }
149 - }
150 - return nil
151 -}
152 -
153 -func validateComposeConfig(rules []ComposeRuleConfig) error {
154 - if len(rules) == 0 {
155 - return errors.New("empty config, need least 1 rule")
156 - }
157 - for i, rule := range rules {
158 - i++
159 - if rule.Selector == "" {
160 - return fmt.Errorf("'rule[%s][%d]->selector' not set", rule.Name, i)
161 - }
162 -
163 - if len(rule.Config) == 0 {
164 - return fmt.Errorf("'rule[%s][%d]->config' not set", rule.Name, i)
165 - }
166 -
167 - for j, conf := range rule.Config {
168 - j++
169 - if conf.Selector == "" {
170 - return fmt.Errorf("'rule[%s][%d]->config[%d]->selector' not set", rule.Name, i, j)
171 - }
172 - if conf.Template == "" {
173 - return fmt.Errorf("'rule[%s][%d]->config[%d]->template' not set", rule.Name, i, j)
174 - }
175 - }
176 - }
177 - return nil
178 -}
179 -
235 func ConvertOldToServices(cls []ClassifyRuleConfig, cmp []ComposeRuleConfig) ([]ServiceRuleConfig, error) {
236 var out []ServiceRuleConfig
237
src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go
+45 -80
@@ -20,7 +20,7 @@ import (
20 )
21
22 func New(cfg Config) (*Pipeline, error) {
23 - if err := validateConfig(cfg); err != nil {
23 + if err := ValidateConfig(cfg); err != nil {
24 return nil, err
25 }
26
@@ -37,27 +37,12 @@ func New(cfg Config) (*Pipeline, error) {
37
38 p.accum.Logger = p.Logger
39
40 - if len(cfg.Services) > 0 {
41 - svr, err := newServiceEngine(cfg.Services)
42 - if err != nil {
43 - return nil, fmt.Errorf("services rules: %v", err)
44 - }
45 - p.svr = svr
46 - svr.Logger = p.Logger
47 - } else {
48 - // Legacy path
49 - clr, err := newTargetClassificator(cfg.Classify)
50 - if err != nil {
51 - return nil, fmt.Errorf("classify rules: %v", err)
52 - }
53 - cmr, err := newConfigComposer(cfg.Compose)
54 - if err != nil {
55 - return nil, fmt.Errorf("compose rules: %v", err)
56 - }
57 - p.clr, p.cmr = clr, cmr
58 - clr.Logger = p.Logger
59 - cmr.Logger = p.Logger
40 + svr, err := newServiceEngine(cfg.Services)
41 + if err != nil {
42 + return nil, fmt.Errorf("services rules: %v", err)
43 }
44 + p.svr = svr
45 + svr.Logger = p.Logger
46
47 if err := p.registerDiscoverers(cfg); err != nil {
48 return nil, err
@@ -78,10 +63,6 @@ type (
63
64 // new
65 svr composer
81 -
82 - // legacy
83 - clr classificator
84 - cmr composer
66 }
67 classificator interface {
68 classify(model.Target) model.Tags
@@ -92,47 +73,51 @@ type (
73 )
74
75 func (p *Pipeline) registerDiscoverers(conf Config) error {
95 - for _, cfg := range conf.Discover {
96 - switch cfg.Discoverer {
97 - case "net_listeners":
98 - cfg.NetListeners.Source = conf.Source
99 - td, err := netlistensd.NewDiscoverer(cfg.NetListeners)
100 - if err != nil {
101 - return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
102 - }
103 - p.discoverers = append(p.discoverers, td)
104 - case "docker":
105 - if hostinfo.IsInsideK8sCluster() {
106 - p.Infof("not registering '%s' discoverer: disabled in k8s environment", cfg.Discoverer)
107 - continue
108 - }
109 - cfg.Docker.Source = conf.Source
110 - td, err := dockersd.NewDiscoverer(cfg.Docker)
111 - if err != nil {
112 - return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
113 - }
114 - p.discoverers = append(p.discoverers, td)
115 - case "k8s":
116 - for _, k8sCfg := range cfg.K8s {
117 - k8sCfg.Source = conf.Source
118 - td, err := k8ssd.NewKubeDiscoverer(k8sCfg)
119 - if err != nil {
120 - return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
121 - }
122 - p.discoverers = append(p.discoverers, td)
123 - }
124 - case "snmp":
125 - cfg.SNMP.Source = conf.Source
126 - td, err := snmpsd.NewDiscoverer(cfg.SNMP)
76 + disc := conf.Discoverer
77 +
78 + if disc.NetListeners != nil {
79 + cfg := *disc.NetListeners
80 + cfg.Source = conf.Source
81 + td, err := netlistensd.NewDiscoverer(cfg)
82 + if err != nil {
83 + return fmt.Errorf("failed to create 'net_listeners' discoverer: %v", err)
84 + }
85 + p.discoverers = append(p.discoverers, td)
86 + }
87 +
88 + if disc.Docker != nil {
89 + if hostinfo.IsInsideK8sCluster() {
90 + p.Info("not registering 'docker' discoverer: disabled in k8s environment")
91 + } else {
92 + cfg := *disc.Docker
93 + cfg.Source = conf.Source
94 + td, err := dockersd.NewDiscoverer(cfg)
95 if err != nil {
128 - return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
96 + return fmt.Errorf("failed to create 'docker' discoverer: %v", err)
97 }
98 p.discoverers = append(p.discoverers, td)
131 - default:
132 - return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
99 }
100 }
101
102 + for _, k8sCfg := range disc.K8s {
103 + k8sCfg.Source = conf.Source
104 + td, err := k8ssd.NewDiscoverer(k8sCfg)
105 + if err != nil {
106 + return fmt.Errorf("failed to create 'k8s' discoverer: %v", err)
107 + }
108 + p.discoverers = append(p.discoverers, td)
109 + }
110 +
111 + if disc.SNMP != nil {
112 + cfg := *disc.SNMP
113 + cfg.Source = conf.Source
114 + td, err := snmpsd.NewDiscoverer(cfg)
115 + if err != nil {
116 + return fmt.Errorf("failed to create 'snmp' discoverer: %v", err)
117 + }
118 + p.discoverers = append(p.discoverers, td)
119 + }
120 +
121 if len(p.discoverers) == 0 {
122 return errors.New("no discoverers registered")
123 }
@@ -233,26 +218,6 @@ func (p *Pipeline) processGroup(tgg model.TargetGroup) *confgroup.Group {
218 }
219 continue
220 }
236 -
237 - // Legacy:
238 - if tags := p.clr.classify(tgt); len(tags) > 0 {
239 - tgt.Tags().Merge(tags)
240 -
241 - if cfgs := p.cmr.compose(tgt); len(cfgs) > 0 {
242 - targetsCache[hash] = cfgs
243 - changed = true
244 -
245 - for _, cfg := range cfgs {
246 - cfg.SetProvider(tgg.Provider())
247 - cfg.SetSource(tgg.Source())
248 - cfg.SetSourceType(confgroup.TypeDiscovered)
249 - if def, ok := p.configDefaults.Lookup(cfg.Module()); ok {
250 - cfg.ApplyDefaults(def)
251 - }
252 - }
253 - }
254 - }
255 -
221 }
222
223 for hash := range targetsCache {
src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline_test.go
+14 -12
@@ -118,6 +118,8 @@ services:
118 wantComposeCalls: 0,
119 wantConfGroups: nil,
120 },
121 + // Note: Legacy classify/compose config is auto-converted to services format during unmarshal.
122 + // The service rule IDs become the compose selectors (bar1, bar2), so module is set to those.
123 "new group with targets": {
124 config: config,
125 discoverers: []model.Discoverer{
@@ -125,10 +127,10 @@ services:
127 newMockTargetGroup("test", "mock1", "mock2"),
128 ),
129 },
128 - wantClassifyCalls: 2,
130 + wantClassifyCalls: 0, // services mode - no classify
131 wantComposeCalls: 2,
132 wantConfGroups: []*confgroup.Group{
131 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2"),
133 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock2-foobar2", "bar2"),
134 },
135 },
136 "existing group with same targets": {
@@ -141,10 +143,10 @@ services:
143 newMockTargetGroup("test", "mock1", "mock2"),
144 ),
145 },
144 - wantClassifyCalls: 2,
146 + wantClassifyCalls: 0, // services mode - no classify
147 wantComposeCalls: 2,
148 wantConfGroups: []*confgroup.Group{
147 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2"),
149 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock2-foobar2", "bar2"),
150 },
151 },
152 "existing group that previously had targets with no targets": {
@@ -157,10 +159,10 @@ services:
159 newMockTargetGroup("test"),
160 ),
161 },
160 - wantClassifyCalls: 2,
162 + wantClassifyCalls: 0, // services mode - no classify
163 wantComposeCalls: 2,
164 wantConfGroups: []*confgroup.Group{
163 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2"),
165 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock2-foobar2", "bar2"),
166 prepareDiscoveredGroup(),
167 },
168 },
@@ -174,11 +176,11 @@ services:
176 newMockTargetGroup("test", "mock1", "mock2", "mock11", "mock22"),
177 ),
178 },
177 - wantClassifyCalls: 4,
179 + wantClassifyCalls: 0, // services mode - no classify
180 wantComposeCalls: 4,
181 wantConfGroups: []*confgroup.Group{
180 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2"),
181 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2", "mock11-foobar1", "mock22-foobar2"),
182 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock2-foobar2", "bar2"),
183 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock11-foobar1", "bar1", "mock2-foobar2", "bar2", "mock22-foobar2", "bar2"),
184 },
185 },
186 "existing group with new targets only": {
@@ -191,11 +193,11 @@ services:
193 newMockTargetGroup("test", "mock11", "mock22"),
194 ),
195 },
194 - wantClassifyCalls: 4,
196 + wantClassifyCalls: 0, // services mode - no classify
197 wantComposeCalls: 4,
198 wantConfGroups: []*confgroup.Group{
197 - prepareDiscoveredGroup("mock1-foobar1", "mock2-foobar2"),
198 - prepareDiscoveredGroup("mock11-foobar1", "mock22-foobar2"),
199 + prepareDiscoveredGroupWithModule("mock1-foobar1", "bar1", "mock2-foobar2", "bar2"),
200 + prepareDiscoveredGroupWithModule("mock11-foobar1", "bar1", "mock22-foobar2", "bar2"),
201 },
202 },
203 "services-only: new group with targets": {
src/go/plugin/go.d/agent/discovery/sd/pipeline/services.go
+7
@@ -141,3 +141,10 @@ func parseConfigTemplateData(bs []byte) ([]confgroup.Config, error) {
141 return nil, errors.New("unknown config format")
142 }
143 }
144 +
145 +func parseTemplate(s string, fmap template.FuncMap) (*template.Template, error) {
146 + return template.New("root").
147 + Option("missingkey=error").
148 + Funcs(fmap).
149 + Parse(s)
150 +}
src/go/plugin/go.d/agent/discovery/sd/pipeline/sim_test.go
-28
@@ -67,30 +67,12 @@ func (sim discoverySim) run(t *testing.T) {
67 return
68 }
69
70 - // --- legacy path ---
71 - clr, err := newTargetClassificator(cfg.Classify)
72 - require.Nil(t, err, "newTargetClassificator")
73 -
74 - cmr, err := newConfigComposer(cfg.Compose)
75 - require.Nil(t, err, "newConfigComposer")
76 -
77 - mockClr := &mockClassificator{clr: clr}
78 - mockCmr := &mockComposer{cmr: cmr}
79 -
80 - pl.clr = mockClr
81 - pl.cmr = mockCmr
82 -
83 - clr.Logger = pl.Logger
84 - cmr.Logger = pl.Logger
85 -
70 groups := sim.collectGroups(t, pl)
71
72 sortConfigGroups(groups)
73 sortConfigGroups(sim.wantConfGroups)
74
75 assert.Equal(t, sim.wantConfGroups, groups)
92 - assert.Equalf(t, sim.wantClassifyCalls, mockClr.calls, "classify calls")
93 - assert.Equalf(t, sim.wantComposeCalls, mockCmr.calls, "compose calls")
76 }
77
78 func (sim discoverySim) collectGroups(t *testing.T, pl *Pipeline) []*confgroup.Group {
@@ -123,16 +105,6 @@ func (sim discoverySim) collectGroups(t *testing.T, pl *Pipeline) []*confgroup.G
105 return groups
106 }
107
126 -type mockClassificator struct {
127 - calls int
128 - clr *targetClassificator
129 -}
130 -
131 -func (m *mockClassificator) classify(tgt model.Target) model.Tags {
132 - m.calls++
133 - return m.clr.classify(tgt)
134 -}
135 -
108 type mockComposer struct {
109 calls int
110 cmr composer
src/go/plugin/go.d/agent/discovery/sd/pipeline_manager.go new
+421
@@ -0,0 +1,421 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "context"
7 + "sync"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/logger"
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
13 +)
14 +
15 +const (
16 + restartGracePeriod = 1 * time.Minute
17 +)
18 +
19 +// PipelineManager manages the lifecycle of discovery pipelines.
20 +// It handles starting, stopping, and restarting pipelines, tracks sources
21 +// for cleanup, and implements a grace period mechanism for restarts.
22 +type PipelineManager struct {
23 + *logger.Logger
24 +
25 + newPipeline func(cfg pipeline.Config) (sdPipeline, error)
26 + send func(ctx context.Context, groups []*confgroup.Group)
27 +
28 + mux sync.Mutex
29 + pipelines map[string]*runningPipeline // [pipelineKey]
30 + pipelineSources map[string]map[string]struct{} // [pipelineKey][source]
31 + pendingRemovals map[string]*pendingRemoval // [pipelineKey]
32 +}
33 +
34 +type runningPipeline struct {
35 + cfg pipeline.Config
36 + cancel context.CancelFunc
37 + done chan struct{}
38 +}
39 +
40 +type pendingRemoval struct {
41 + sources map[string]struct{}
42 + timestamp time.Time
43 +}
44 +
45 +// NewPipelineManager creates a new PipelineManager.
46 +func NewPipelineManager(
47 + log *logger.Logger,
48 + newPipeline func(cfg pipeline.Config) (sdPipeline, error),
49 + send func(ctx context.Context, groups []*confgroup.Group),
50 +) *PipelineManager {
51 + return &PipelineManager{
52 + Logger: log,
53 + newPipeline: newPipeline,
54 + send: send,
55 + pipelines: make(map[string]*runningPipeline),
56 + pipelineSources: make(map[string]map[string]struct{}),
57 + pendingRemovals: make(map[string]*pendingRemoval),
58 + }
59 +}
60 +
61 +// Start starts a new pipeline with the given key and config.
62 +// If a pipeline with the same key is already running, it will be stopped first.
63 +func (m *PipelineManager) Start(ctx context.Context, key string, cfg pipeline.Config) error {
64 + m.mux.Lock()
65 +
66 + // Stop existing pipeline if any (no grace period - this is initial start or replace)
67 + sp := m.removePipelineLocked(key, true)
68 +
69 + m.mux.Unlock()
70 +
71 + // Wait for old pipeline and cleanup outside the lock
72 + if sp != nil {
73 + m.waitAndCleanup(key, sp)
74 + }
75 +
76 + m.mux.Lock()
77 + defer m.mux.Unlock()
78 +
79 + return m.startPipelineLocked(ctx, key, cfg)
80 +}
81 +
82 +// Stop stops a pipeline and sends removal groups for all its tracked sources.
83 +func (m *PipelineManager) Stop(key string) {
84 + m.mux.Lock()
85 + sp := m.removePipelineLocked(key, true)
86 + m.mux.Unlock()
87 +
88 + // Wait for pipeline and cleanup outside the lock
89 + if sp != nil {
90 + m.waitAndCleanup(key, sp)
91 + }
92 +}
93 +
94 +// Restart stops a pipeline and starts it with new config, using grace period
95 +// to avoid removing discovered jobs that will be re-discovered.
96 +func (m *PipelineManager) Restart(ctx context.Context, key string, cfg pipeline.Config) error {
97 + // Validate new config first by creating the pipeline (outside lock)
98 + pl, err := m.newPipeline(cfg)
99 + if err != nil {
100 + // New config is invalid, keep old pipeline running
101 + return err
102 + }
103 +
104 + m.mux.Lock()
105 +
106 + // Mark current sources as pending removal (grace period)
107 + // Merge with existing pending removals to avoid losing sources from previous restarts
108 + if sources, ok := m.pipelineSources[key]; ok && len(sources) > 0 {
109 + if existing, ok := m.pendingRemovals[key]; ok {
110 + // Merge: add current sources to existing pending removals
111 + for src := range sources {
112 + existing.sources[src] = struct{}{}
113 + }
114 + existing.timestamp = time.Now() // Reset grace period
115 + m.Debugf("pipeline '%s': merged %d sources into pending removal (now %d total)", key, len(sources), len(existing.sources))
116 + } else {
117 + m.pendingRemovals[key] = &pendingRemoval{
118 + sources: copySourcesMap(sources),
119 + timestamp: time.Now(),
120 + }
121 + m.Debugf("pipeline '%s': marked %d sources for pending removal (grace period)", key, len(sources))
122 + }
123 + }
124 +
125 + // Stop old pipeline without cleanup (sources are pending, not removed)
126 + sp := m.removePipelineLocked(key, false)
127 +
128 + m.mux.Unlock()
129 +
130 + // Wait for old pipeline outside the lock
131 + if sp != nil {
132 + m.waitForPipeline(key, sp)
133 + }
134 +
135 + m.mux.Lock()
136 + defer m.mux.Unlock()
137 +
138 + // Start the already-created new pipeline
139 + return m.startPipelineWithInstanceLocked(ctx, key, cfg, pl)
140 +}
141 +
142 +// StopAll stops all running pipelines with cleanup.
143 +func (m *PipelineManager) StopAll() {
144 + // Collect and remove all pipelines while holding the lock
145 + m.mux.Lock()
146 + toStop := make(map[string]*stoppedPipeline, len(m.pipelines))
147 + for key := range m.pipelines {
148 + if sp := m.removePipelineLocked(key, true); sp != nil {
149 + toStop[key] = sp
150 + }
151 + }
152 + m.mux.Unlock()
153 +
154 + // Wait for all pipelines and cleanup outside the lock
155 + for key, sp := range toStop {
156 + m.waitAndCleanup(key, sp)
157 + }
158 +}
159 +
160 +// RunGracePeriodCleanup runs the grace period cleanup loop.
161 +// It should be called as a goroutine and will run until ctx is cancelled.
162 +func (m *PipelineManager) RunGracePeriodCleanup(ctx context.Context) {
163 + tk := time.NewTicker(5 * time.Second)
164 + defer tk.Stop()
165 +
166 + for {
167 + select {
168 + case <-ctx.Done():
169 + return
170 + case <-tk.C:
171 + m.processGracePeriodRemovals(ctx)
172 + }
173 + }
174 +}
175 +
176 +// IsRunning returns true if a pipeline with the given key is running.
177 +func (m *PipelineManager) IsRunning(key string) bool {
178 + m.mux.Lock()
179 + defer m.mux.Unlock()
180 +
181 + _, ok := m.pipelines[key]
182 + return ok
183 +}
184 +
185 +// Keys returns the keys of all running pipelines.
186 +func (m *PipelineManager) Keys() []string {
187 + m.mux.Lock()
188 + defer m.mux.Unlock()
189 +
190 + keys := make([]string, 0, len(m.pipelines))
191 + for k := range m.pipelines {
192 + keys = append(keys, k)
193 + }
194 + return keys
195 +}
196 +
197 +func (m *PipelineManager) startPipelineLocked(ctx context.Context, key string, cfg pipeline.Config) error {
198 + pl, err := m.newPipeline(cfg)
199 + if err != nil {
200 + return err
201 + }
202 +
203 + return m.startPipelineWithInstanceLocked(ctx, key, cfg, pl)
204 +}
205 +
206 +func (m *PipelineManager) startPipelineWithInstanceLocked(ctx context.Context, key string, cfg pipeline.Config, pl sdPipeline) error {
207 + // No check for existing pipeline needed here:
208 + // All operations for the same pipeline key are processed sequentially
209 + // in ServiceDiscovery.run()'s select loop (both file config events and
210 + // dyncfg commands), so concurrent Start/Restart calls for the same key
211 + // cannot occur.
212 +
213 + plCtx, cancel := context.WithCancel(ctx)
214 + done := make(chan struct{})
215 +
216 + rp := &runningPipeline{
217 + cfg: cfg,
218 + cancel: cancel,
219 + done: done,
220 + }
221 +
222 + m.pipelines[key] = rp
223 + m.pipelineSources[key] = make(map[string]struct{})
224 +
225 + go func() {
226 + defer close(done)
227 + m.runPipeline(plCtx, key, pl)
228 + }()
229 +
230 + m.Infof("pipeline '%s' started", key)
231 + return nil
232 +}
233 +
234 +func (m *PipelineManager) runPipeline(ctx context.Context, key string, pl sdPipeline) {
235 + groups := make(chan []*confgroup.Group)
236 + done := make(chan struct{})
237 +
238 + go func() {
239 + defer close(done)
240 + pl.Run(ctx, groups)
241 + }()
242 +
243 + for {
244 + select {
245 + case <-ctx.Done():
246 + select {
247 + case <-done:
248 + case <-time.After(10 * time.Second):
249 + m.Warningf("pipeline '%s': timeout waiting for shutdown", key)
250 + }
251 + return
252 + case <-done:
253 + return
254 + case grps := <-groups:
255 + m.onGroupsReceived(ctx, key, grps)
256 + }
257 + }
258 +}
259 +
260 +func (m *PipelineManager) onGroupsReceived(ctx context.Context, key string, groups []*confgroup.Group) {
261 + m.mux.Lock()
262 +
263 + // Ignore groups if pipeline is no longer tracked (was removed)
264 + if _, exists := m.pipelines[key]; !exists {
265 + m.mux.Unlock()
266 + return
267 + }
268 +
269 + // Track sources
270 + for _, grp := range groups {
271 + if m.pipelineSources[key] == nil {
272 + m.pipelineSources[key] = make(map[string]struct{})
273 + }
274 + m.pipelineSources[key][grp.Source] = struct{}{}
275 +
276 + // Cancel pending removal for re-discovered sources
277 + if pending, ok := m.pendingRemovals[key]; ok {
278 + if _, wasPending := pending.sources[grp.Source]; wasPending {
279 + delete(pending.sources, grp.Source)
280 + m.Debugf("pipeline '%s': source '%s' re-discovered, cancelled pending removal", key, grp.Source)
281 + }
282 + }
283 + }
284 +
285 + m.mux.Unlock()
286 +
287 + // Forward groups
288 + m.send(ctx, groups)
289 +}
290 +
291 +// stoppedPipeline holds info needed to complete pipeline shutdown outside the lock.
292 +type stoppedPipeline struct {
293 + rp *runningPipeline
294 + sourcesToRemove []string
295 +}
296 +
297 +// removePipelineLocked removes a pipeline from the map, cancels it, and optionally
298 +// collects sources for cleanup. Returns info needed to complete shutdown outside the lock.
299 +// Must be called with m.mux held.
300 +func (m *PipelineManager) removePipelineLocked(key string, cleanup bool) *stoppedPipeline {
301 + rp, ok := m.pipelines[key]
302 + if !ok {
303 + return nil
304 + }
305 +
306 + // Cancel the pipeline (it will stop asynchronously)
307 + rp.cancel()
308 +
309 + // Remove from map so it's not visible to other operations
310 + delete(m.pipelines, key)
311 +
312 + result := &stoppedPipeline{rp: rp}
313 +
314 + if cleanup {
315 + result.sourcesToRemove = m.collectSourcesForCleanupLocked(key)
316 + }
317 +
318 + return result
319 +}
320 +
321 +// waitForPipeline waits for a pipeline to finish without sending removal notifications.
322 +// Used when sources are in pending removal state (grace period) and shouldn't be cleaned up.
323 +// Must be called without holding m.mux.
324 +func (m *PipelineManager) waitForPipeline(key string, sp *stoppedPipeline) {
325 + <-sp.rp.done
326 + m.Infof("pipeline '%s' stopped", key)
327 +}
328 +
329 +// waitAndCleanup waits for pipeline to finish and sends removal notifications.
330 +// Must be called without holding m.mux.
331 +func (m *PipelineManager) waitAndCleanup(key string, sp *stoppedPipeline) {
332 + <-sp.rp.done
333 + m.Infof("pipeline '%s' stopped", key)
334 +
335 + // Send removals outside the lock
336 + if len(sp.sourcesToRemove) > 0 {
337 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
338 + defer cancel()
339 +
340 + for _, source := range sp.sourcesToRemove {
341 + m.Debugf("pipeline '%s': sending removal for source '%s'", key, source)
342 + m.send(ctx, []*confgroup.Group{{Source: source}})
343 + }
344 + }
345 +}
346 +
347 +// collectSourcesForCleanupLocked collects all sources that need removal notifications,
348 +// including tracked sources and pending removals. Must be called with m.mux held.
349 +func (m *PipelineManager) collectSourcesForCleanupLocked(key string) []string {
350 + sourceSet := make(map[string]struct{})
351 +
352 + // Collect tracked sources
353 + if sources, ok := m.pipelineSources[key]; ok {
354 + for src := range sources {
355 + sourceSet[src] = struct{}{}
356 + }
357 + }
358 +
359 + // Collect pending removal sources (these would otherwise be orphaned)
360 + if pending, ok := m.pendingRemovals[key]; ok {
361 + for src := range pending.sources {
362 + sourceSet[src] = struct{}{}
363 + }
364 + }
365 +
366 + // Clean up maps
367 + delete(m.pipelineSources, key)
368 + delete(m.pendingRemovals, key)
369 +
370 + // Convert to slice
371 + sources := make([]string, 0, len(sourceSet))
372 + for src := range sourceSet {
373 + sources = append(sources, src)
374 + }
375 + return sources
376 +}
377 +
378 +func (m *PipelineManager) processGracePeriodRemovals(ctx context.Context) {
379 + // Collect expired removals while holding the lock
380 + type removal struct {
381 + key string
382 + source string
383 + }
384 + var toRemove []removal
385 +
386 + m.mux.Lock()
387 + now := time.Now()
388 +
389 + for key, pending := range m.pendingRemovals {
390 + if now.Sub(pending.timestamp) < restartGracePeriod {
391 + continue
392 + }
393 +
394 + // Grace period expired - collect sources that weren't re-discovered
395 + for source := range pending.sources {
396 + toRemove = append(toRemove, removal{key: key, source: source})
397 +
398 + // Remove from tracked sources
399 + if sources, ok := m.pipelineSources[key]; ok {
400 + delete(sources, source)
401 + }
402 + }
403 +
404 + delete(m.pendingRemovals, key)
405 + }
406 + m.mux.Unlock()
407 +
408 + // Send removals outside the lock to avoid blocking other operations
409 + for _, r := range toRemove {
410 + m.Infof("pipeline '%s': grace period expired, removing source '%s'", r.key, r.source)
411 + m.send(ctx, []*confgroup.Group{{Source: r.source}})
412 + }
413 +}
414 +
415 +func copySourcesMap(src map[string]struct{}) map[string]struct{} {
416 + dst := make(map[string]struct{}, len(src))
417 + for k, v := range src {
418 + dst[k] = v
419 + }
420 + return dst
421 +}
src/go/plugin/go.d/agent/discovery/sd/pipeline_manager_test.go new
+563
@@ -0,0 +1,563 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package sd
4 +
5 +import (
6 + "context"
7 + "errors"
8 + "fmt"
9 + "sync"
10 + "sync/atomic"
11 + "testing"
12 + "time"
13 +
14 + "github.com/netdata/netdata/go/plugins/logger"
15 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
17 +
18 + "github.com/stretchr/testify/assert"
19 + "github.com/stretchr/testify/require"
20 +)
21 +
22 +func TestPipelineManager_Start(t *testing.T) {
23 + tests := map[string]struct {
24 + setup func(m *PipelineManager, ctx context.Context)
25 + key string
26 + cfg pipeline.Config
27 + wantErr bool
28 + wantRunning bool
29 + }{
30 + "start new pipeline": {
31 + key: "test-pipeline",
32 + cfg: pipeline.Config{Name: "test"},
33 + wantRunning: true,
34 + },
35 + "start replaces existing pipeline": {
36 + setup: func(m *PipelineManager, ctx context.Context) {
37 + _ = m.Start(ctx, "test-pipeline", pipeline.Config{Name: "old"})
38 + },
39 + key: "test-pipeline",
40 + cfg: pipeline.Config{Name: "new"},
41 + wantRunning: true,
42 + },
43 + "start with invalid config fails": {
44 + key: "test-pipeline",
45 + cfg: pipeline.Config{Name: "invalid"},
46 + wantErr: true,
47 + },
48 + }
49 +
50 + for name, tc := range tests {
51 + t.Run(name, func(t *testing.T) {
52 + ctx, cancel := context.WithCancel(context.Background())
53 + defer cancel()
54 +
55 + var sentGroups []*confgroup.Group
56 + var mu sync.Mutex
57 +
58 + m := NewPipelineManager(
59 + logger.New(),
60 + mockNewPipeline,
61 + func(_ context.Context, groups []*confgroup.Group) {
62 + mu.Lock()
63 + sentGroups = append(sentGroups, groups...)
64 + mu.Unlock()
65 + },
66 + )
67 +
68 + if tc.setup != nil {
69 + tc.setup(m, ctx)
70 + }
71 +
72 + err := m.Start(ctx, tc.key, tc.cfg)
73 +
74 + if tc.wantErr {
75 + assert.Error(t, err)
76 + } else {
77 + assert.NoError(t, err)
78 + }
79 + assert.Equal(t, tc.wantRunning, m.IsRunning(tc.key))
80 + })
81 + }
82 +}
83 +
84 +func TestPipelineManager_Stop(t *testing.T) {
85 + t.Run("stop sends removal for tracked sources", func(t *testing.T) {
86 + ctx, cancel := context.WithCancel(context.Background())
87 + defer cancel()
88 +
89 + var sentGroups []*confgroup.Group
90 + var mu sync.Mutex
91 +
92 + m := NewPipelineManager(
93 + logger.New(),
94 + mockNewPipelineWithGroups(
95 + []*confgroup.Group{
96 + {Source: "source1", Configs: []confgroup.Config{}},
97 + {Source: "source2", Configs: []confgroup.Config{}},
98 + },
99 + ),
100 + func(_ context.Context, groups []*confgroup.Group) {
101 + mu.Lock()
102 + sentGroups = append(sentGroups, groups...)
103 + mu.Unlock()
104 + },
105 + )
106 +
107 + err := m.Start(ctx, "test-pipeline", pipeline.Config{Name: "test"})
108 + require.NoError(t, err)
109 +
110 + // Wait for groups to be received
111 + time.Sleep(100 * time.Millisecond)
112 +
113 + m.Stop("test-pipeline")
114 +
115 + // Wait for stop to complete
116 + time.Sleep(100 * time.Millisecond)
117 +
118 + assert.False(t, m.IsRunning("test-pipeline"))
119 +
120 + mu.Lock()
121 + // Should have initial groups + removal groups
122 + // Removal groups have nil Configs (not empty slice)
123 + var removalSources []string
124 + for _, g := range sentGroups {
125 + if g.Configs == nil {
126 + removalSources = append(removalSources, g.Source)
127 + }
128 + }
129 + mu.Unlock()
130 +
131 + assert.ElementsMatch(t, []string{"source1", "source2"}, removalSources)
132 + })
133 +
134 + t.Run("stop non-existent pipeline is no-op", func(t *testing.T) {
135 + m := NewPipelineManager(
136 + logger.New(),
137 + mockNewPipeline,
138 + func(_ context.Context, _ []*confgroup.Group) {},
139 + )
140 +
141 + // Should not panic
142 + m.Stop("non-existent")
143 + assert.False(t, m.IsRunning("non-existent"))
144 + })
145 +}
146 +
147 +func TestPipelineManager_Restart(t *testing.T) {
148 + t.Run("restart uses grace period for overlapping sources", func(t *testing.T) {
149 + ctx, cancel := context.WithCancel(context.Background())
150 + defer cancel()
151 +
152 + var sentGroups []*confgroup.Group
153 + var mu sync.Mutex
154 +
155 + // First pipeline discovers source1 and source2
156 + firstPipelineGroups := []*confgroup.Group{
157 + {Source: "source1", Configs: []confgroup.Config{}},
158 + {Source: "source2", Configs: []confgroup.Config{}},
159 + }
160 +
161 + // Second pipeline re-discovers source1 but not source2
162 + secondPipelineGroups := []*confgroup.Group{
163 + {Source: "source1", Configs: []confgroup.Config{}},
164 + }
165 +
166 + callCount := 0
167 + m := NewPipelineManager(
168 + logger.New(),
169 + func(cfg pipeline.Config) (sdPipeline, error) {
170 + callCount++
171 + if callCount == 1 {
172 + return newMockPipelineWithGroups(cfg.Name, firstPipelineGroups), nil
173 + }
174 + return newMockPipelineWithGroups(cfg.Name, secondPipelineGroups), nil
175 + },
176 + func(_ context.Context, groups []*confgroup.Group) {
177 + mu.Lock()
178 + sentGroups = append(sentGroups, groups...)
179 + mu.Unlock()
180 + },
181 + )
182 +
183 + // Start first pipeline
184 + err := m.Start(ctx, "test-pipeline", pipeline.Config{Name: "v1"})
185 + require.NoError(t, err)
186 +
187 + // Wait for first pipeline to send groups
188 + time.Sleep(100 * time.Millisecond)
189 +
190 + // Restart with new config
191 + err = m.Restart(ctx, "test-pipeline", pipeline.Config{Name: "v2"})
192 + require.NoError(t, err)
193 +
194 + // Wait for second pipeline to send groups
195 + time.Sleep(100 * time.Millisecond)
196 +
197 + assert.True(t, m.IsRunning("test-pipeline"))
198 +
199 + // source1 should NOT be in pending removals (re-discovered)
200 + // source2 should be in pending removals (not re-discovered)
201 + mu.Lock()
202 + // At this point, no removals should have been sent yet (within grace period)
203 + // Removal groups have nil Configs (not empty slice)
204 + var removalSources []string
205 + for _, g := range sentGroups {
206 + if g.Configs == nil {
207 + removalSources = append(removalSources, g.Source)
208 + }
209 + }
210 + mu.Unlock()
211 +
212 + assert.Empty(t, removalSources, "no removals should be sent within grace period")
213 +
214 + // Verify pending removals state
215 + m.mux.Lock()
216 + pending, ok := m.pendingRemovals["test-pipeline"]
217 + m.mux.Unlock()
218 +
219 + assert.True(t, ok, "should have pending removals")
220 + if ok {
221 + _, hasSource2 := pending.sources["source2"]
222 + _, hasSource1 := pending.sources["source1"]
223 + assert.True(t, hasSource2, "source2 should be pending removal")
224 + assert.False(t, hasSource1, "source1 should NOT be pending (re-discovered)")
225 + }
226 + })
227 +
228 + t.Run("restart with invalid config keeps old pipeline", func(t *testing.T) {
229 + ctx, cancel := context.WithCancel(context.Background())
230 + defer cancel()
231 +
232 + callCount := 0
233 + m := NewPipelineManager(
234 + logger.New(),
235 + func(cfg pipeline.Config) (sdPipeline, error) {
236 + callCount++
237 + if cfg.Name == "invalid" {
238 + return nil, errors.New("invalid config")
239 + }
240 + return newMockPipeline(cfg.Name), nil
241 + },
242 + func(_ context.Context, _ []*confgroup.Group) {},
243 + )
244 +
245 + // Start first pipeline
246 + err := m.Start(ctx, "test-pipeline", pipeline.Config{Name: "v1"})
247 + require.NoError(t, err)
248 + assert.True(t, m.IsRunning("test-pipeline"))
249 +
250 + // Try to restart with invalid config
251 + err = m.Restart(ctx, "test-pipeline", pipeline.Config{Name: "invalid"})
252 + assert.Error(t, err)
253 +
254 + // Old pipeline should still be running
255 + assert.True(t, m.IsRunning("test-pipeline"))
256 + })
257 +}
258 +
259 +func TestPipelineManager_StopAll(t *testing.T) {
260 + t.Run("stops all pipelines and sends removals", func(t *testing.T) {
261 + ctx, cancel := context.WithCancel(context.Background())
262 + defer cancel()
263 +
264 + var sentGroups []*confgroup.Group
265 + var mu sync.Mutex
266 +
267 + m := NewPipelineManager(
268 + logger.New(),
269 + mockNewPipelineWithGroups(
270 + []*confgroup.Group{{Source: "source1", Configs: []confgroup.Config{}}},
271 + ),
272 + func(_ context.Context, groups []*confgroup.Group) {
273 + mu.Lock()
274 + sentGroups = append(sentGroups, groups...)
275 + mu.Unlock()
276 + },
277 + )
278 +
279 + // Start multiple pipelines
280 + _ = m.Start(ctx, "pipeline1", pipeline.Config{Name: "p1"})
281 + _ = m.Start(ctx, "pipeline2", pipeline.Config{Name: "p2"})
282 + _ = m.Start(ctx, "pipeline3", pipeline.Config{Name: "p3"})
283 +
284 + // Wait for pipelines to send groups
285 + time.Sleep(100 * time.Millisecond)
286 +
287 + assert.Len(t, m.Keys(), 3)
288 +
289 + m.StopAll()
290 +
291 + // Wait for stop to complete
292 + time.Sleep(100 * time.Millisecond)
293 +
294 + assert.Empty(t, m.Keys())
295 + assert.False(t, m.IsRunning("pipeline1"))
296 + assert.False(t, m.IsRunning("pipeline2"))
297 + assert.False(t, m.IsRunning("pipeline3"))
298 + })
299 +}
300 +
301 +func TestPipelineManager_RunGracePeriodCleanup(t *testing.T) {
302 + t.Run("expired pending removals are cleaned up", func(t *testing.T) {
303 + ctx, cancel := context.WithCancel(context.Background())
304 + defer cancel()
305 +
306 + var sentGroups []*confgroup.Group
307 + var mu sync.Mutex
308 +
309 + m := NewPipelineManager(
310 + logger.New(),
311 + mockNewPipeline,
312 + func(_ context.Context, groups []*confgroup.Group) {
313 + mu.Lock()
314 + sentGroups = append(sentGroups, groups...)
315 + mu.Unlock()
316 + },
317 + )
318 +
319 + // Manually add a pending removal with expired timestamp
320 + m.mux.Lock()
321 + m.pendingRemovals["test-pipeline"] = &pendingRemoval{
322 + sources: map[string]struct{}{"expired-source": {}},
323 + timestamp: time.Now().Add(-65 * time.Second), // older than 1 minute grace period
324 + }
325 + m.pipelineSources["test-pipeline"] = map[string]struct{}{"expired-source": {}}
326 + m.mux.Unlock()
327 +
328 + // Run one iteration of cleanup
329 + m.processGracePeriodRemovals(ctx)
330 +
331 + // Check that removal was sent
332 + // Removal groups have nil Configs
333 + mu.Lock()
334 + var removalSources []string
335 + for _, g := range sentGroups {
336 + if g.Configs == nil {
337 + removalSources = append(removalSources, g.Source)
338 + }
339 + }
340 + mu.Unlock()
341 +
342 + assert.Contains(t, removalSources, "expired-source")
343 +
344 + // Pending removal should be cleared
345 + m.mux.Lock()
346 + _, hasPending := m.pendingRemovals["test-pipeline"]
347 + m.mux.Unlock()
348 + assert.False(t, hasPending)
349 + })
350 +
351 + t.Run("non-expired pending removals are preserved", func(t *testing.T) {
352 + ctx, cancel := context.WithCancel(context.Background())
353 + defer cancel()
354 +
355 + var sentGroups []*confgroup.Group
356 + var mu sync.Mutex
357 +
358 + m := NewPipelineManager(
359 + logger.New(),
360 + mockNewPipeline,
361 + func(_ context.Context, groups []*confgroup.Group) {
362 + mu.Lock()
363 + sentGroups = append(sentGroups, groups...)
364 + mu.Unlock()
365 + },
366 + )
367 +
368 + // Manually add a pending removal with recent timestamp
369 + m.mux.Lock()
370 + m.pendingRemovals["test-pipeline"] = &pendingRemoval{
371 + sources: map[string]struct{}{"recent-source": {}},
372 + timestamp: time.Now(), // just now - not expired
373 + }
374 + m.mux.Unlock()
375 +
376 + // Run one iteration of cleanup
377 + m.processGracePeriodRemovals(ctx)
378 +
379 + // No removal should be sent
380 + // Removal groups have nil Configs
381 + mu.Lock()
382 + var removalSources []string
383 + for _, g := range sentGroups {
384 + if g.Configs == nil {
385 + removalSources = append(removalSources, g.Source)
386 + }
387 + }
388 + mu.Unlock()
389 +
390 + assert.Empty(t, removalSources)
391 +
392 + // Pending removal should still exist
393 + m.mux.Lock()
394 + _, hasPending := m.pendingRemovals["test-pipeline"]
395 + m.mux.Unlock()
396 + assert.True(t, hasPending)
397 + })
398 +}
399 +
400 +func TestPipelineManager_IsRunning(t *testing.T) {
401 + ctx, cancel := context.WithCancel(context.Background())
402 + defer cancel()
403 +
404 + m := NewPipelineManager(
405 + logger.New(),
406 + mockNewPipeline,
407 + func(_ context.Context, _ []*confgroup.Group) {},
408 + )
409 +
410 + assert.False(t, m.IsRunning("test"))
411 +
412 + _ = m.Start(ctx, "test", pipeline.Config{Name: "test"})
413 + assert.True(t, m.IsRunning("test"))
414 +
415 + m.Stop("test")
416 + time.Sleep(50 * time.Millisecond)
417 + assert.False(t, m.IsRunning("test"))
418 +}
419 +
420 +func TestPipelineManager_Keys(t *testing.T) {
421 + ctx, cancel := context.WithCancel(context.Background())
422 + defer cancel()
423 +
424 + m := NewPipelineManager(
425 + logger.New(),
426 + mockNewPipeline,
427 + func(_ context.Context, _ []*confgroup.Group) {},
428 + )
429 +
430 + assert.Empty(t, m.Keys())
431 +
432 + _ = m.Start(ctx, "p1", pipeline.Config{Name: "p1"})
433 + _ = m.Start(ctx, "p2", pipeline.Config{Name: "p2"})
434 +
435 + keys := m.Keys()
436 + assert.Len(t, keys, 2)
437 + assert.ElementsMatch(t, []string{"p1", "p2"}, keys)
438 +}
439 +
440 +func TestPipelineManager_ConcurrentOperations(t *testing.T) {
441 + // Note: Concurrent operations on the SAME key are not supported and cannot
442 + // happen in production (ServiceDiscovery.run() processes events sequentially).
443 + // This test verifies concurrent operations on DIFFERENT keys work correctly.
444 +
445 + ctx, cancel := context.WithCancel(context.Background())
446 + defer cancel()
447 +
448 + // Track created and stopped pipelines to detect leaks
449 + var created, stopped atomic.Int64
450 +
451 + mockFactory := func(cfg pipeline.Config) (sdPipeline, error) {
452 + created.Add(1)
453 + return &trackingMockPipeline{stopped: &stopped}, nil
454 + }
455 +
456 + m := NewPipelineManager(
457 + logger.New(),
458 + mockFactory,
459 + func(_ context.Context, _ []*confgroup.Group) {},
460 + )
461 +
462 + var wg sync.WaitGroup
463 +
464 + // Concurrent starts for different keys
465 + for i := 0; i < 10; i++ {
466 + wg.Add(1)
467 + go func(i int) {
468 + defer wg.Done()
469 + key := fmt.Sprintf("pipeline-%d", i)
470 + _ = m.Start(ctx, key, pipeline.Config{Name: key})
471 + }(i)
472 + }
473 +
474 + // Concurrent IsRunning checks
475 + for i := 0; i < 10; i++ {
476 + wg.Add(1)
477 + go func(i int) {
478 + defer wg.Done()
479 + _ = m.IsRunning(fmt.Sprintf("pipeline-%d", i))
480 + }(i)
481 + }
482 +
483 + // Concurrent Keys checks
484 + for i := 0; i < 10; i++ {
485 + wg.Add(1)
486 + go func() {
487 + defer wg.Done()
488 + _ = m.Keys()
489 + }()
490 + }
491 +
492 + wg.Wait()
493 +
494 + // Should have 10 pipelines running (one per unique key)
495 + assert.Len(t, m.Keys(), 10)
496 + for i := 0; i < 10; i++ {
497 + assert.True(t, m.IsRunning(fmt.Sprintf("pipeline-%d", i)))
498 + }
499 +
500 + // Stop all pipelines
501 + m.StopAll()
502 +
503 + // Wait for all pipelines to stop
504 + assert.Eventually(t, func() bool {
505 + return created.Load() == stopped.Load()
506 + }, time.Second*5, time.Millisecond*100,
507 + "leaked pipelines: created=%d, stopped=%d", created.Load(), stopped.Load())
508 +}
509 +
510 +// mockNewPipeline creates a mock pipeline that does nothing.
511 +func mockNewPipeline(cfg pipeline.Config) (sdPipeline, error) {
512 + if cfg.Name == "invalid" {
513 + return nil, errors.New("invalid config")
514 + }
515 + return newMockPipeline(cfg.Name), nil
516 +}
517 +
518 +// mockNewPipelineWithGroups creates a factory that produces pipelines that send specific groups.
519 +func mockNewPipelineWithGroups(groups []*confgroup.Group) func(cfg pipeline.Config) (sdPipeline, error) {
520 + return func(cfg pipeline.Config) (sdPipeline, error) {
521 + if cfg.Name == "invalid" {
522 + return nil, errors.New("invalid config")
523 + }
524 + return newMockPipelineWithGroups(cfg.Name, groups), nil
525 + }
526 +}
527 +
528 +type testMockPipeline struct {
529 + name string
530 + groups []*confgroup.Group
531 +}
532 +
533 +func newMockPipeline(name string) *testMockPipeline {
534 + return &testMockPipeline{name: name}
535 +}
536 +
537 +func newMockPipelineWithGroups(name string, groups []*confgroup.Group) *testMockPipeline {
538 + return &testMockPipeline{name: name, groups: groups}
539 +}
540 +
541 +func (p *testMockPipeline) Run(ctx context.Context, out chan<- []*confgroup.Group) {
542 + // Send initial groups if any
543 + if len(p.groups) > 0 {
544 + select {
545 + case out <- p.groups:
546 + case <-ctx.Done():
547 + return
548 + }
549 + }
550 +
551 + // Wait for cancellation
552 + <-ctx.Done()
553 +}
554 +
555 +// trackingMockPipeline tracks when it stops for leak detection
556 +type trackingMockPipeline struct {
557 + stopped *atomic.Int64
558 +}
559 +
560 +func (p *trackingMockPipeline) Run(ctx context.Context, _ chan<- []*confgroup.Group) {
561 + <-ctx.Done()
562 + p.stopped.Add(1)
563 +}
src/go/plugin/go.d/agent/discovery/sd/sd.go
+249 -44
@@ -4,21 +4,34 @@ package sd
4
5 import (
6 "context"
7 - "fmt"
7 "log/slog"
8 + "os"
9 "sync"
10
11 "github.com/netdata/netdata/go/plugins/logger"
12 "github.com/netdata/netdata/go/plugins/pkg/multipath"
13 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14 + "github.com/netdata/netdata/go/plugins/pkg/safewriter"
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
16 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
18 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
19
16 - "gopkg.in/yaml.v2"
20 + "github.com/mattn/go-isatty"
21 )
22
23 +var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsTerminal(os.Stdin.Fd())
24 +
25 +// disableDyncfg controls whether SD dyncfg integration is active.
26 +// When true (default): templates are not registered, file configs auto-start without dyncfg.
27 +// When false: full dyncfg integration (used in tests).
28 +// TODO: Remove this flag after SD dyncfg feature is validated in production.
29 +var disableDyncfg = true
30 +
31 type Config struct {
32 ConfigDefaults confgroup.Registry
33 ConfDir multipath.MultiPath
34 + FnReg functions.Registry
35 }
36
37 func NewServiceDiscovery(cfg Config) (*ServiceDiscovery, error) {
@@ -30,10 +43,14 @@ func NewServiceDiscovery(cfg Config) (*ServiceDiscovery, error) {
43 Logger: log,
44 confProv: newConfFileReader(log, cfg.ConfDir),
45 configDefaults: cfg.ConfigDefaults,
46 + fnReg: cfg.FnReg,
47 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.Stdout)),
48 + seenConfigs: newSeenSDConfigs(),
49 + exposedConfigs: newExposedSDConfigs(),
50 + dyncfgCh: make(chan dyncfg.Function, 1),
51 newPipeline: func(config pipeline.Config) (sdPipeline, error) {
52 return pipeline.New(config)
53 },
36 - pipelines: make(map[string]func()),
54 }
55
56 return d, nil
@@ -46,8 +63,20 @@ type (
63 confProv confFileProvider
64
65 configDefaults confgroup.Registry
66 + fnReg functions.Registry
67 + dyncfgApi *dyncfg.Responder
68 + seenConfigs *seenSDConfigs // All discovered configs by UID
69 + exposedConfigs *exposedSDConfigs // Configs exposed to dyncfg by Key
70 + dyncfgCh chan dyncfg.Function
71 newPipeline func(config pipeline.Config) (sdPipeline, error)
50 - pipelines map[string]func()
72 +
73 + ctx context.Context
74 + mgr *PipelineManager
75 +
76 + // waitCfgOnOff holds the pipeline key we're waiting for enable/disable on.
77 + // When set, we only process dyncfg commands (not new file configs).
78 + // This ensures netdata can send enable/disable before we process more configs.
79 + waitCfgOnOff string
80 }
81 sdPipeline interface {
82 Run(ctx context.Context, in chan<- []*confgroup.Group)
@@ -64,7 +93,25 @@ func (d *ServiceDiscovery) String() string {
93
94 func (d *ServiceDiscovery) Run(ctx context.Context, in chan<- []*confgroup.Group) {
95 d.Info("instance is started")
67 - defer func() { d.cleanup(); d.Info("instance is stopped") }()
96 + defer func() { d.unregisterDyncfgTemplates(); d.Info("instance is stopped") }()
97 +
98 + // Store context for dyncfg commands
99 + d.ctx = ctx
100 +
101 + // Create pipeline manager with send function that forwards to output channel
102 + // NOTE: Must be created BEFORE registering dyncfg templates, as dyncfg commands use mgr
103 + send := func(ctx context.Context, groups []*confgroup.Group) {
104 + select {
105 + case <-ctx.Done():
106 + case in <- groups:
107 + }
108 + }
109 +
110 + d.mgr = NewPipelineManager(d.Logger, d.newPipeline, send)
111 +
112 + // Register dyncfg templates for discoverer types
113 + // NOTE: Must be AFTER mgr creation, as dyncfg commands use mgr
114 + d.registerDyncfgTemplates(ctx)
115
116 var wg sync.WaitGroup
117
@@ -72,76 +119,234 @@ func (d *ServiceDiscovery) Run(ctx context.Context, in chan<- []*confgroup.Group
119 go func() { defer wg.Done(); d.confProv.run(ctx) }()
120
121 wg.Add(1)
75 - go func() { defer wg.Done(); d.run(ctx, in) }()
122 + go func() { defer wg.Done(); d.run(ctx) }()
123 +
124 + wg.Add(1)
125 + go func() { defer wg.Done(); d.mgr.RunGracePeriodCleanup(ctx) }()
126
127 wg.Wait()
78 - <-ctx.Done()
128 +
129 + // Cleanup all pipelines on shutdown
130 + d.mgr.StopAll()
131 }
132
81 -func (d *ServiceDiscovery) run(ctx context.Context, in chan<- []*confgroup.Group) {
133 +func (d *ServiceDiscovery) run(ctx context.Context) {
134 for {
83 - select {
84 - case <-ctx.Done():
85 - return
86 - case cfg := <-d.confProv.configs():
87 - if cfg.source == "" {
88 - continue
135 + if d.waitCfgOnOff != "" {
136 + // Waiting for enable/disable command - only process dyncfg commands
137 + select {
138 + case <-ctx.Done():
139 + return
140 + case fn := <-d.dyncfgCh:
141 + d.dyncfgSeqExec(fn)
142 }
90 - if len(cfg.content) == 0 {
91 - d.removePipeline(cfg)
92 - } else {
93 - d.addPipeline(ctx, cfg, in)
143 + } else {
144 + select {
145 + case <-ctx.Done():
146 + return
147 + case cfg := <-d.confProv.configs():
148 + if cfg.source == "" {
149 + continue
150 + }
151 + if len(cfg.content) == 0 {
152 + d.removePipeline(cfg)
153 + } else {
154 + d.addPipeline(ctx, cfg)
155 + }
156 + case fn := <-d.dyncfgCh:
157 + d.dyncfgSeqExec(fn)
158 }
159 }
160 }
161 }
162
163 func (d *ServiceDiscovery) removePipeline(conf confFile) {
100 - if stop, ok := d.pipelines[conf.source]; ok {
101 - d.Infof("received an empty config, stopping the pipeline ('%s')", conf.source)
102 - delete(d.pipelines, conf.source)
103 - stop()
164 + seenCfgs := d.seenConfigs.lookupBySource(conf.source)
165 + if len(seenCfgs) == 0 {
166 + return
167 + }
168 +
169 + d.Infof("removing %d config(s) from source '%s'", len(seenCfgs), conf.source)
170 +
171 + for _, scfg := range seenCfgs {
172 + // Remove from seen cache
173 + d.seenConfigs.remove(scfg)
174 +
175 + // Check if this was the exposed config
176 + ecfg, ok := d.exposedConfigs.lookup(scfg)
177 + if !ok || scfg.UID() != ecfg.UID() {
178 + // Not exposed or different config is exposed - skip dyncfg remove
179 + continue
180 + }
181 +
182 + // This was the exposed config - stop pipeline and remove from dyncfg
183 + if d.mgr.IsRunning(scfg.PipelineKey()) {
184 + d.mgr.Stop(scfg.PipelineKey())
185 + }
186 +
187 + d.exposedConfigs.remove(scfg)
188 + if !disableDyncfg {
189 + d.dyncfgSDJobRemove(scfg.DiscovererType(), scfg.Name())
190 + }
191 }
192 }
193
107 -func (d *ServiceDiscovery) addPipeline(ctx context.Context, conf confFile, in chan<- []*confgroup.Group) {
108 - var cfg pipeline.Config
194 +func (d *ServiceDiscovery) addPipeline(ctx context.Context, conf confFile) {
195 + // Create sdConfig directly from YAML (cleans name for dyncfg compatibility)
196 + sourceType := sourceTypeFromPath(conf.source)
197 + pipelineKey := pipelineKeyFromSource(conf.source)
198
110 - if err := yaml.Unmarshal(conf.content, &cfg); err != nil {
111 - d.Errorf("failed to unmarshal pipeline config '%s' (%s): %v", cfg.Name, conf.source, err)
199 + scfg, err := newSDConfigFromYAML(conf.content, conf.source, sourceType, pipelineKey)
200 + if err != nil {
201 + d.Errorf("failed to unmarshal config from '%s': %v", conf.source, err)
202 return
203 }
204
115 - if cfg.Disabled {
116 - d.Infof("pipeline config is disabled '%s' (%s)", cfg.Name, conf.source)
205 + // Check if disabled
206 + if disabled, _ := scfg["disabled"].(bool); disabled {
207 + d.Infof("pipeline '%s' is disabled in config", scfg.Name())
208 return
209 }
210
120 - cfg.Source = fmt.Sprintf("file=%s", conf.source)
121 - cfg.ConfigDefaults = d.configDefaults
211 + if scfg.DiscovererType() == "" {
212 + d.Errorf("config '%s' has no discoverer configured", conf.source)
213 + return
214 + }
215
123 - pl, err := d.newPipeline(cfg)
124 - if err != nil {
125 - d.Error(err)
216 + if scfg.Name() == "" {
217 + d.Errorf("config '%s' has no name configured", conf.source)
218 return
219 }
220
129 - if stop, ok := d.pipelines[conf.source]; ok {
130 - stop()
221 + d.addConfig(ctx, scfg)
222 +}
223 +
224 +// addConfig handles adding a config with priority handling.
225 +// This is the core logic matching jobmgr pattern.
226 +func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
227 + // For file sources: One file = one config. If the file previously provided a different config,
228 + // remove the old one first. This handles the case where a file config name changes.
229 + if scfg.SourceType() != confgroup.TypeDyncfg {
230 + d.removeOldConfigsFromSource(scfg.Source(), scfg.Key())
231 }
232
133 - var wg sync.WaitGroup
134 - plCtx, cancel := context.WithCancel(ctx)
233 + // Always add to seen cache
234 + d.seenConfigs.add(scfg)
235
136 - wg.Add(1)
137 - go func() { defer wg.Done(); pl.Run(plCtx, in) }()
236 + // Check if there's an existing exposed config with the same key
237 + ecfg, exists := d.exposedConfigs.lookup(scfg)
238
139 - stop := func() { cancel(); wg.Wait() }
140 - d.pipelines[conf.source] = stop
239 + if !exists {
240 + // No existing config - expose this one
241 + scfg.SetStatus(dyncfg.StatusAccepted)
242 + d.exposedConfigs.add(scfg)
243 +
244 + if disableDyncfg {
245 + // Dyncfg disabled - start pipeline directly
246 + d.startPipelineDirectly(ctx, scfg)
247 + } else {
248 + d.dyncfgSDJobCreate(scfg.DiscovererType(), scfg.Name(), scfg.SourceType(), scfg.Source(), scfg.Status())
249 + if isTerminal || d.dyncfgCh == nil {
250 + // Auto-enable in terminal mode or tests
251 + d.autoEnableConfig(scfg)
252 + } else {
253 + // Wait for netdata to send enable/disable
254 + d.waitCfgOnOff = scfg.PipelineKey()
255 + }
256 + }
257 + return
258 + }
259 +
260 + // Existing config found - apply priority rules
261 + sp, ep := scfg.SourceTypePriority(), ecfg.SourceTypePriority()
262 +
263 + // Higher priority wins. If same priority and existing is running, keep existing (stability).
264 + if ep > sp || (ep == sp && ecfg.Status() == dyncfg.StatusRunning) {
265 + d.Debugf("config '%s': keeping existing (priority: existing=%d new=%d, status=%s)",
266 + scfg.Key(), ep, sp, ecfg.Status())
267 + return
268 + }
269 +
270 + // New config wins - stop existing if running
271 + d.Infof("config '%s': replacing existing (priority: existing=%d new=%d)", scfg.Key(), ep, sp)
272 +
273 + if ecfg.Status() == dyncfg.StatusRunning {
274 + d.mgr.Stop(ecfg.PipelineKey())
275 + }
276 +
277 + // Replace in exposed cache
278 + scfg.SetStatus(dyncfg.StatusAccepted)
279 + d.exposedConfigs.add(scfg)
280 +
281 + if disableDyncfg {
282 + // Dyncfg disabled - start pipeline directly
283 + d.startPipelineDirectly(ctx, scfg)
284 + } else {
285 + // Update dyncfg (remove old, create new with new source)
286 + d.dyncfgSDJobRemove(ecfg.DiscovererType(), ecfg.Name())
287 + d.dyncfgSDJobCreate(scfg.DiscovererType(), scfg.Name(), scfg.SourceType(), scfg.Source(), scfg.Status())
288 +
289 + if isTerminal || d.dyncfgCh == nil {
290 + d.autoEnableConfig(scfg)
291 + } else {
292 + d.waitCfgOnOff = scfg.PipelineKey()
293 + }
294 + }
295 }
296
143 -func (d *ServiceDiscovery) cleanup() {
144 - for _, stop := range d.pipelines {
145 - stop()
297 +// removeOldConfigsFromSource removes configs from the same source that have a different key.
298 +// This handles the case where a file's config name changes.
299 +// Note: We don't stop the pipeline here - the new config will stop it when it starts via
300 +// PipelineManager.Start (which stops any existing pipeline with the same key).
301 +// This ensures that if the new config fails to start, the old pipeline keeps running.
302 +func (d *ServiceDiscovery) removeOldConfigsFromSource(source, newKey string) {
303 + oldCfgs := d.seenConfigs.lookupBySource(source)
304 + for _, oldCfg := range oldCfgs {
305 + if oldCfg.Key() == newKey {
306 + continue // Same config, skip
307 + }
308 +
309 + // Different config from same source - remove from caches
310 + d.seenConfigs.remove(oldCfg)
311 +
312 + // If it was exposed, remove from exposed cache and dyncfg
313 + // But DON'T stop the pipeline - let the new config's enable handle that
314 + if ecfg, ok := d.exposedConfigs.lookup(oldCfg); ok && ecfg.UID() == oldCfg.UID() {
315 + d.exposedConfigs.remove(oldCfg)
316 + if !disableDyncfg {
317 + d.dyncfgSDJobRemove(oldCfg.DiscovererType(), oldCfg.Name())
318 + }
319 + }
320 + }
321 +}
322 +
323 +// autoEnableConfig enables a config without waiting for netdata's enable command.
324 +func (d *ServiceDiscovery) autoEnableConfig(cfg sdConfig) {
325 + fn := dyncfg.NewFunction(functions.Function{
326 + Args: []string{d.dyncfgJobID(cfg.DiscovererType(), cfg.Name()), "enable"},
327 + })
328 + d.dyncfgCmdEnable(fn)
329 +}
330 +
331 +// startPipelineDirectly starts a pipeline without dyncfg integration.
332 +// Used when disableDyncfg is true.
333 +func (d *ServiceDiscovery) startPipelineDirectly(ctx context.Context, cfg sdConfig) {
334 + pipelineCfg, err := cfg.ToPipelineConfig(d.configDefaults)
335 + if err != nil {
336 + d.Errorf("failed to parse config '%s': %v", cfg.Name(), err)
337 + return
338 }
339 +
340 + if err := d.mgr.Start(ctx, cfg.PipelineKey(), pipelineCfg); err != nil {
341 + d.Errorf("failed to start pipeline '%s': %v", cfg.Name(), err)
342 + return
343 + }
344 +
345 + d.exposedConfigs.updateStatus(cfg, dyncfg.StatusRunning)
346 +}
347 +
348 +// pipelineKeyFromSource extracts a pipeline key from a file source path.
349 +// For now, we use the file path as key. This will be extended for dyncfg.
350 +func pipelineKeyFromSource(source string) string {
351 + return source
352 }
src/go/plugin/go.d/agent/discovery/sd/sd_test.go
+141 -5
@@ -5,11 +5,19 @@ package sd
5 import (
6 "testing"
7
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlistensd"
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
12
13 "gopkg.in/yaml.v2"
14 )
15
16 +func init() {
17 + // Enable dyncfg integration for tests (disabled by default in production)
18 + disableDyncfg = false
19 +}
20 +
21 func TestServiceDiscovery_Run(t *testing.T) {
22 tests := map[string]discoverySim{
23 "add pipeline": {
@@ -36,14 +44,14 @@ func TestServiceDiscovery_Run(t *testing.T) {
44 },
45 },
46 "re-add pipeline multiple times": {
47 + // With the new stability logic, re-adding the same config from the same source
48 + // when it's already running is a no-op. Only 1 pipeline should be created.
49 configs: []confFile{
50 prepareConfigFile("source", "name"),
51 prepareConfigFile("source", "name"),
52 prepareConfigFile("source", "name"),
53 },
54 wantPipelines: []*mockPipeline{
45 - {name: "name", started: true, stopped: true},
46 - {name: "name", started: true, stopped: true},
55 {name: "name", started: true, stopped: false},
56 },
57 },
@@ -69,7 +77,7 @@ func TestServiceDiscovery_Run(t *testing.T) {
77 prepareConfigFile("source", "invalid"),
78 },
79 wantPipelines: []*mockPipeline{
72 - {name: "name", started: true, stopped: false},
80 + {name: "name", started: true, stopped: true},
81 },
82 },
83 }
@@ -82,7 +90,13 @@ func TestServiceDiscovery_Run(t *testing.T) {
90 }
91
92 func prepareConfigFile(source, name string) confFile {
85 - bs, _ := yaml.Marshal(pipeline.Config{Name: name})
93 + cfg := pipeline.Config{
94 + Name: name,
95 + Discoverer: pipeline.DiscovererConfig{
96 + NetListeners: &netlistensd.Config{},
97 + },
98 + }
99 + bs, _ := yaml.Marshal(cfg)
100
101 return confFile{
102 source: source,
@@ -97,10 +111,132 @@ func prepareEmptyConfigFile(source string) confFile {
111 }
112
113 func prepareDisabledConfigFile(source, name string) confFile {
100 - bs, _ := yaml.Marshal(pipeline.Config{Name: name, Disabled: true})
114 + cfg := pipeline.Config{
115 + Name: name,
116 + Disabled: true,
117 + Discoverer: pipeline.DiscovererConfig{
118 + NetListeners: &netlistensd.Config{},
119 + },
120 + }
121 + bs, _ := yaml.Marshal(cfg)
122
123 return confFile{
124 source: source,
125 content: bs,
126 }
127 }
128 +
129 +// prepareStockConfigFile creates a config from a stock path (priority 2)
130 +func prepareStockConfigFile(name string) confFile {
131 + return prepareConfigFile("/usr/lib/netdata/conf.d/sd/"+name+".conf", name)
132 +}
133 +
134 +// prepareUserConfigFile creates a config from a user path (priority 8)
135 +// User paths contain ".d/" pattern
136 +func prepareUserConfigFile(name string) confFile {
137 + return prepareConfigFile("/etc/netdata/sd.d/"+name+".conf", name)
138 +}
139 +
140 +func TestServiceDiscovery_Priority(t *testing.T) {
141 + tests := map[string]discoverySimExt{
142 + "stock then user with same name - user wins": {
143 + // Stock config arrives first, then user config with same name
144 + // User has higher priority, should replace stock
145 + configs: []confFile{
146 + prepareStockConfigFile("myconfig"),
147 + prepareUserConfigFile("myconfig"),
148 + },
149 + wantPipelines: []*mockPipeline{
150 + {name: "myconfig", started: true, stopped: true}, // stock stopped
151 + {name: "myconfig", started: true, stopped: false}, // user running
152 + },
153 + wantExposedCount: 1,
154 + wantExposed: []wantExposedCfg{
155 + {discovererType: "net_listeners", name: "myconfig", sourceType: confgroup.TypeUser, status: dyncfg.StatusRunning},
156 + },
157 + },
158 + "user then stock with same name - user keeps": {
159 + // User config arrives first, then stock config with same name
160 + // User has higher priority, should keep user
161 + configs: []confFile{
162 + prepareUserConfigFile("myconfig"),
163 + prepareStockConfigFile("myconfig"),
164 + },
165 + wantPipelines: []*mockPipeline{
166 + {name: "myconfig", started: true, stopped: false}, // user keeps running
167 + },
168 + wantExposedCount: 1,
169 + wantExposed: []wantExposedCfg{
170 + {discovererType: "net_listeners", name: "myconfig", sourceType: confgroup.TypeUser, status: dyncfg.StatusRunning},
171 + },
172 + },
173 + "stock then stock with same name - existing keeps if running": {
174 + // Two stock configs with same name from different files
175 + // Same priority + running = keep existing
176 + configs: []confFile{
177 + prepareConfigFile("/usr/lib/netdata/conf.d/sd/file1.conf", "myconfig"),
178 + prepareConfigFile("/usr/lib/netdata/conf.d/sd/file2.conf", "myconfig"),
179 + },
180 + wantPipelines: []*mockPipeline{
181 + {name: "myconfig", started: true, stopped: false}, // first stock keeps running
182 + },
183 + wantExposedCount: 1,
184 + wantExposed: []wantExposedCfg{
185 + {discovererType: "net_listeners", name: "myconfig", sourceType: confgroup.TypeStock, status: dyncfg.StatusRunning},
186 + },
187 + },
188 + "user then user with same name - existing keeps if running": {
189 + // Two user configs with same name from different files
190 + // Same priority + running = keep existing
191 + configs: []confFile{
192 + prepareConfigFile("/etc/netdata/sd.d/file1.conf", "myconfig"),
193 + prepareConfigFile("/etc/netdata/sd.d/file2.conf", "myconfig"),
194 + },
195 + wantPipelines: []*mockPipeline{
196 + {name: "myconfig", started: true, stopped: false}, // first user keeps running
197 + },
198 + wantExposedCount: 1,
199 + wantExposed: []wantExposedCfg{
200 + {discovererType: "net_listeners", name: "myconfig", sourceType: confgroup.TypeUser, status: dyncfg.StatusRunning},
201 + },
202 + },
203 + "remove non-exposed config - no dyncfg change": {
204 + // User config exposed, then stock config arrives (not exposed due to lower priority)
205 + // When stock file is "removed" (empty content), nothing should change
206 + configs: []confFile{
207 + prepareUserConfigFile("myconfig"),
208 + prepareStockConfigFile("myconfig"),
209 + prepareEmptyConfigFile("/usr/lib/netdata/conf.d/sd/myconfig.conf"), // remove stock
210 + },
211 + wantPipelines: []*mockPipeline{
212 + {name: "myconfig", started: true, stopped: false}, // user keeps running
213 + },
214 + wantExposedCount: 1,
215 + wantExposed: []wantExposedCfg{
216 + {discovererType: "net_listeners", name: "myconfig", sourceType: confgroup.TypeUser, status: dyncfg.StatusRunning},
217 + },
218 + },
219 + "multiple configs different names": {
220 + // Stock and user configs with different names - both should run
221 + configs: []confFile{
222 + prepareStockConfigFile("stock-config"),
223 + prepareUserConfigFile("user-config"),
224 + },
225 + wantPipelines: []*mockPipeline{
226 + {name: "stock-config", started: true, stopped: false},
227 + {name: "user-config", started: true, stopped: false},
228 + },
229 + wantExposedCount: 2,
230 + wantExposed: []wantExposedCfg{
231 + {discovererType: "net_listeners", name: "stock-config", sourceType: confgroup.TypeStock, status: dyncfg.StatusRunning},
232 + {discovererType: "net_listeners", name: "user-config", sourceType: confgroup.TypeUser, status: dyncfg.StatusRunning},
233 + },
234 + },
235 + }
236 +
237 + for name, sim := range tests {
238 + t.Run(name, func(t *testing.T) {
239 + sim.run(t)
240 + })
241 + }
242 +}
src/go/plugin/go.d/agent/discovery/sd/sim_test.go
+82 -1
@@ -3,6 +3,7 @@
3 package sd
4
5 import (
6 + "bytes"
7 "context"
8 "errors"
9 "sync"
@@ -10,8 +11,11 @@ import (
11 "time"
12
13 "github.com/netdata/netdata/go/plugins/logger"
14 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
15 + "github.com/netdata/netdata/go/plugins/pkg/safewriter"
16 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
17 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
18 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
19
20 "github.com/stretchr/testify/assert"
21 )
@@ -23,8 +27,81 @@ type discoverySim struct {
27 wantPipelines []*mockPipeline
28 }
29
30 +// discoverySimExt is an extended simulation that also checks exposed configs
31 +type discoverySimExt struct {
32 + configs []confFile
33 + wantPipelines []*mockPipeline
34 + wantExposedCount int
35 + wantExposed []wantExposedCfg
36 +}
37 +
38 +type wantExposedCfg struct {
39 + discovererType string
40 + name string
41 + sourceType string
42 + status dyncfg.Status
43 +}
44 +
45 +func (sim *discoverySimExt) run(t *testing.T) {
46 + fact := &mockFactory{}
47 + var buf bytes.Buffer
48 + mgr := &ServiceDiscovery{
49 + Logger: logger.New(),
50 + newPipeline: func(config pipeline.Config) (sdPipeline, error) {
51 + return fact.create(config)
52 + },
53 + confProv: &mockConfigProvider{
54 + confFiles: sim.configs,
55 + ch: make(chan confFile),
56 + },
57 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
58 + seenConfigs: newSeenSDConfigs(),
59 + exposedConfigs: newExposedSDConfigs(),
60 + // dyncfgCh is intentionally nil to trigger auto-enable in tests
61 + }
62 +
63 + in := make(chan<- []*confgroup.Group)
64 + done := make(chan struct{})
65 + ctx, cancel := context.WithCancel(context.Background())
66 +
67 + go func() { defer close(done); mgr.Run(ctx, in) }()
68 +
69 + time.Sleep(time.Second * 3)
70 +
71 + lock.Lock()
72 + if sim.wantPipelines != nil {
73 + assert.Equalf(t, sim.wantPipelines, fact.pipelines, "pipelines mismatch")
74 + }
75 +
76 + // Check exposed configs count
77 + if sim.wantExposedCount > 0 {
78 + assert.Equal(t, sim.wantExposedCount, mgr.exposedConfigs.count(), "exposed configs count")
79 + }
80 +
81 + // Check specific exposed configs
82 + for _, want := range sim.wantExposed {
83 + cfg, ok := mgr.exposedConfigs.lookup(newLookupConfig(want.discovererType, want.name))
84 + if !assert.Truef(t, ok, "exposed config '%s:%s' not found", want.discovererType, want.name) {
85 + continue
86 + }
87 + assert.Equal(t, want.sourceType, cfg.SourceType(), "exposed config '%s:%s' sourceType", want.discovererType, want.name)
88 + assert.Equal(t, want.status, cfg.Status(), "exposed config '%s:%s' status", want.discovererType, want.name)
89 + }
90 + lock.Unlock()
91 +
92 + cancel()
93 +
94 + timeout := time.Second * 5
95 + select {
96 + case <-done:
97 + case <-time.After(timeout):
98 + t.Errorf("sd failed to exit in %s", timeout)
99 + }
100 +}
101 +
102 func (sim *discoverySim) run(t *testing.T) {
103 fact := &mockFactory{}
104 + var buf bytes.Buffer
105 mgr := &ServiceDiscovery{
106 Logger: logger.New(),
107 newPipeline: func(config pipeline.Config) (sdPipeline, error) {
@@ -34,7 +111,11 @@ func (sim *discoverySim) run(t *testing.T) {
111 confFiles: sim.configs,
112 ch: make(chan confFile),
113 },
37 - pipelines: make(map[string]func()),
114 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
115 + seenConfigs: newSeenSDConfigs(),
116 + exposedConfigs: newExposedSDConfigs(),
117 + // dyncfgCh is intentionally nil to trigger auto-enable in tests
118 + // (simulates terminal mode where netdata is not available)
119 }
120
121 in := make(chan<- []*confgroup.Group)
src/go/plugin/go.d/agent/dyncfg/function.go new
+149
@@ -0,0 +1,149 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import (
6 + "encoding/json"
7 + "fmt"
8 + "strings"
9 +
10 + "gopkg.in/yaml.v2"
11 +
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
13 +)
14 +
15 +// Function wraps functions.Function with dyncfg-specific accessor and helper methods.
16 +type Function struct {
17 + fn functions.Function
18 +}
19 +
20 +// NewFunction creates a new dyncfg Function wrapper.
21 +func NewFunction(fn functions.Function) Function {
22 + return Function{fn: fn}
23 +}
24 +
25 +// Fn returns the underlying functions.Function.
26 +// Use this when passing to APIs that expect functions.Function.
27 +func (f Function) Fn() functions.Function {
28 + return f.fn
29 +}
30 +
31 +// UID returns the function's unique identifier.
32 +func (f Function) UID() string {
33 + return f.fn.UID
34 +}
35 +
36 +// Source returns the function's source field.
37 +func (f Function) Source() string {
38 + return f.fn.Source
39 +}
40 +
41 +// Payload returns the function's payload.
42 +func (f Function) Payload() []byte {
43 + return f.fn.Payload
44 +}
45 +
46 +// ContentType returns the function's content type.
47 +func (f Function) ContentType() string {
48 + return f.fn.ContentType
49 +}
50 +
51 +// Command returns the dyncfg command from Args[1].
52 +// Returns empty Command if args has fewer than 2 elements.
53 +func (f Function) Command() Command {
54 + if len(f.fn.Args) < 2 {
55 + return ""
56 + }
57 + return Command(strings.ToLower(f.fn.Args[1]))
58 +}
59 +
60 +// ID returns the config ID from Args[0].
61 +// Returns empty string if args is empty.
62 +func (f Function) ID() string {
63 + if len(f.fn.Args) < 1 {
64 + return ""
65 + }
66 + return f.fn.Args[0]
67 +}
68 +
69 +// JobName returns the job name from Args[2] (used in add command).
70 +// Returns empty string if args has fewer than 3 elements.
71 +// Sanitizes the name by replacing spaces and colons with underscores.
72 +func (f Function) JobName() string {
73 + if len(f.fn.Args) < 3 {
74 + return ""
75 + }
76 + name := strings.ReplaceAll(f.fn.Args[2], " ", "_")
77 + name = strings.ReplaceAll(name, ":", "_")
78 + return name
79 +}
80 +
81 +// User returns the user value from the Source field.
82 +func (f Function) User() string {
83 + return f.SourceValue("user")
84 +}
85 +
86 +// SourceValue extracts a value from the Source field.
87 +// Source format is "key1=value1,key2=value2,...".
88 +func (f Function) SourceValue(key string) string {
89 + prefix := key + "="
90 + for _, part := range strings.Split(f.fn.Source, ",") {
91 + if v, ok := strings.CutPrefix(part, prefix); ok {
92 + return strings.TrimSpace(v)
93 + }
94 + }
95 + return ""
96 +}
97 +
98 +// HasPayload returns true if the function has a non-empty payload.
99 +func (f Function) HasPayload() bool {
100 + return len(f.fn.Payload) > 0
101 +}
102 +
103 +// ValidateArgs checks if the function has at least the required number of arguments.
104 +// Returns an error with a descriptive message if validation fails.
105 +func (f Function) ValidateArgs(required int) error {
106 + if len(f.fn.Args) < required {
107 + return fmt.Errorf("missing required arguments: need %d, got %d", required, len(f.fn.Args))
108 + }
109 + return nil
110 +}
111 +
112 +// ValidateHasPayload checks if the function has a payload.
113 +// Returns an error if the payload is empty.
114 +func (f Function) ValidateHasPayload() error {
115 + if !f.HasPayload() {
116 + return fmt.Errorf("missing configuration payload")
117 + }
118 + return nil
119 +}
120 +
121 +// UnmarshalPayload unmarshals the payload into dst based on ContentType.
122 +// Uses JSON for "application/json", YAML otherwise.
123 +func (f Function) UnmarshalPayload(dst any) error {
124 + if f.fn.ContentType == "application/json" {
125 + return f.unmarshalJSON(dst)
126 + }
127 + return f.unmarshalYAML(dst)
128 +}
129 +
130 +// unmarshalJSON unmarshals the payload as JSON into dst.
131 +func (f Function) unmarshalJSON(dst any) error {
132 + if err := json.Unmarshal(f.fn.Payload, dst); err != nil {
133 + return fmt.Errorf("failed to unmarshal JSON payload: %w", err)
134 + }
135 + return nil
136 +}
137 +
138 +// unmarshalYAML unmarshals the payload as YAML into dst.
139 +func (f Function) unmarshalYAML(dst any) error {
140 + if err := yaml.Unmarshal(f.fn.Payload, dst); err != nil {
141 + return fmt.Errorf("failed to unmarshal YAML payload: %w", err)
142 + }
143 + return nil
144 +}
145 +
146 +// IsContentTypeJSON returns true if the content type is JSON.
147 +func (f Function) IsContentTypeJSON() bool {
148 + return f.fn.ContentType == "application/json"
149 +}
src/go/plugin/go.d/agent/dyncfg/responder.go
+11 -12
@@ -9,7 +9,6 @@ import (
9 "time"
10
11 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
12 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
12 )
13
14 // Responder handles standardized responses for dyncfg operations
@@ -23,8 +22,8 @@ func NewResponder(api *netdataapi.API) *Responder {
22 }
23
24 // SendCodef sends a response with a specific code and message
26 -func (r *Responder) SendCodef(fn functions.Function, code int, message string, args ...any) {
27 - if fn.UID == "" {
25 +func (r *Responder) SendCodef(fn Function, code int, message string, args ...any) {
26 + if fn.UID() == "" {
27 return
28 }
29
@@ -53,7 +52,7 @@ func (r *Responder) SendCodef(fn functions.Function, code int, message string, a
52 }
53
54 r.api.FUNCRESULT(netdataapi.FunctionResult{
56 - UID: fn.UID,
55 + UID: fn.UID(),
56 ContentType: "application/json",
57 Payload: string(payload),
58 Code: strconv.Itoa(code),
@@ -62,18 +61,18 @@ func (r *Responder) SendCodef(fn functions.Function, code int, message string, a
61 }
62
63 // SendJSON sends a JSON payload response with HTTP 200 status
65 -func (r *Responder) SendJSON(fn functions.Function, payload string) {
64 +func (r *Responder) SendJSON(fn Function, payload string) {
65 r.sendPayload(fn, payload, "application/json")
66 }
67
68 // SendJSONWithCode sends a JSON payload response with a specific HTTP status code
70 -func (r *Responder) SendJSONWithCode(fn functions.Function, payload string, code int) {
71 - if fn.UID == "" {
69 +func (r *Responder) SendJSONWithCode(fn Function, payload string, code int) {
70 + if fn.UID() == "" {
71 return
72 }
73
74 r.api.FUNCRESULT(netdataapi.FunctionResult{
76 - UID: fn.UID,
75 + UID: fn.UID(),
76 ContentType: "application/json",
77 Payload: payload,
78 Code: strconv.Itoa(code),
@@ -82,18 +81,18 @@ func (r *Responder) SendJSONWithCode(fn functions.Function, payload string, code
81 }
82
83 // SendYAML sends a YAML payload response
85 -func (r *Responder) SendYAML(fn functions.Function, payload string) {
84 +func (r *Responder) SendYAML(fn Function, payload string) {
85 r.sendPayload(fn, payload, "application/yaml")
86 }
87
88 // sendPayload sends a response with a specific payload and content type
90 -func (r *Responder) sendPayload(fn functions.Function, payload, contentType string) {
91 - if fn.UID == "" {
89 +func (r *Responder) sendPayload(fn Function, payload, contentType string) {
90 + if fn.UID() == "" {
91 return
92 }
93
94 r.api.FUNCRESULT(netdataapi.FunctionResult{
96 - UID: fn.UID,
95 + UID: fn.UID(),
96 ContentType: contentType,
97 Payload: payload,
98 Code: "200",
src/go/plugin/go.d/agent/functions/registry.go new
+12
@@ -0,0 +1,12 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package functions
4 +
5 +// Registry defines the interface for registering function handlers.
6 +// Both jobmgr and service discovery use this to register dyncfg handlers.
7 +type Registry interface {
8 + Register(name string, fn func(Function))
9 + Unregister(name string)
10 + RegisterPrefix(name, prefix string, fn func(Function))
11 + UnregisterPrefix(name string, prefix string)
12 +}
src/go/plugin/go.d/agent/jobmgr/di.go
+2 -6
@@ -11,9 +11,5 @@ type Vnodes interface {
11 Lookup(key string) (*vnodes.VirtualNode, bool)
12 }
13
14 -type FunctionRegistry interface {
15 - Register(name string, fn func(functions.Function))
16 - Unregister(name string)
17 - RegisterPrefix(name, prefix string, fn func(functions.Function))
18 - UnregisterPrefix(name string, prefix string)
19 -}
14 +// FunctionRegistry is an alias to functions.Registry for backward compatibility.
15 +type FunctionRegistry = functions.Registry
src/go/plugin/go.d/agent/jobmgr/dyncfg.go
+16 -44
@@ -3,21 +3,22 @@
3 package jobmgr
4
5 import (
6 - "encoding/json"
7 - "fmt"
8 - "reflect"
6 "strings"
7
11 - "gopkg.in/yaml.v2"
12 -
8 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
9 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
10 )
11
17 -func (m *Manager) dyncfgConfig(fn functions.Function) {
18 - if len(fn.Args) < 2 {
19 - m.Warningf("dyncfg: %s: missing required arguments, want 3 got %d", fn.Name, len(fn.Args))
20 - m.dyncfgApi.SendCodef(fn, 400, "Missing required arguments. Need at least 2, but got %d.", len(fn.Args))
12 +// dyncfgConfigHandler wraps dyncfgConfig to convert functions.Function to dyncfg.Function.
13 +// This is needed because functions.Registry expects func(functions.Function).
14 +func (m *Manager) dyncfgConfigHandler(fn functions.Function) {
15 + m.dyncfgConfig(dyncfg.NewFunction(fn))
16 +}
17 +
18 +func (m *Manager) dyncfgConfig(fn dyncfg.Function) {
19 + if err := fn.ValidateArgs(2); err != nil {
20 + m.Warningf("dyncfg: %v", err)
21 + m.dyncfgApi.SendCodef(fn, 400, "%v", err)
22 return
23 }
24
@@ -27,13 +28,11 @@ func (m *Manager) dyncfgConfig(fn functions.Function) {
28 default:
29 }
30
30 - //m.Infof("QQ FN: '%s'", fn)
31 -
31 m.dyncfgQueuedExec(fn)
32 }
33
35 -func (m *Manager) dyncfgQueuedExec(fn functions.Function) {
36 - id := fn.Args[0]
34 +func (m *Manager) dyncfgQueuedExec(fn dyncfg.Function) {
35 + id := fn.ID()
36
37 switch {
38 case strings.HasPrefix(id, m.dyncfgCollectorPrefixValue()):
@@ -41,12 +40,12 @@ func (m *Manager) dyncfgQueuedExec(fn functions.Function) {
40 case strings.HasPrefix(id, m.dyncfgVnodePrefixValue()):
41 m.dyncfgVnodeExec(fn)
42 default:
44 - m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Name, id)
43 + m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, id)
44 }
45 }
46
48 -func (m *Manager) dyncfgSeqExec(fn functions.Function) {
49 - id := fn.Args[0]
47 +func (m *Manager) dyncfgSeqExec(fn dyncfg.Function) {
48 + id := fn.ID()
49
50 switch {
51 case strings.HasPrefix(id, m.dyncfgCollectorPrefixValue()):
@@ -54,33 +53,6 @@ func (m *Manager) dyncfgSeqExec(fn functions.Function) {
53 case strings.HasPrefix(id, m.dyncfgVnodePrefixValue()):
54 m.dyncfgVnodeSeqExec(fn)
55 default:
57 - m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Name, id)
58 - }
59 -}
60 -
61 -func unmarshalPayload(dst any, fn functions.Function) error {
62 - if v := reflect.ValueOf(dst); v.Kind() != reflect.Ptr || v.IsNil() {
63 - return fmt.Errorf("invalid config: expected a pointer to a struct, got a %s", v.Type())
64 - }
65 - if fn.ContentType == "application/json" {
66 - return json.Unmarshal(fn.Payload, dst)
67 - }
68 - return yaml.Unmarshal(fn.Payload, dst)
69 -}
70 -
71 -func getFnSourceValue(fn functions.Function, key string) string {
72 - prefix := key + "="
73 - for _, part := range strings.Split(fn.Source, ",") {
74 - if v, ok := strings.CutPrefix(part, prefix); ok {
75 - return strings.TrimSpace(v)
76 - }
77 - }
78 - return ""
79 -}
80 -
81 -func getDyncfgCommand(fn functions.Function) dyncfg.Command {
82 - if len(fn.Args) < 2 {
83 - return ""
56 + m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, id)
57 }
85 - return dyncfg.Command(strings.ToLower(fn.Args[1]))
58 }
src/go/plugin/go.d/agent/jobmgr/dyncfg_collector.go
+64 -65
@@ -19,7 +19,6 @@ import (
19 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
20 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
21 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
22 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
22 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
23 )
24
@@ -98,8 +97,8 @@ func (m *Manager) dyncfgJobStatus(cfg confgroup.Config, status dyncfg.Status) {
97 m.dyncfgApi.ConfigStatus(m.dyncfgJobID(cfg), status)
98 }
99
101 -func (m *Manager) dyncfgCollectorExec(fn functions.Function) {
102 - switch getDyncfgCommand(fn) {
100 +func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
101 + switch fn.Command() {
102 case dyncfg.CommandUserconfig:
103 m.dyncfgConfigUserconfig(fn)
104 return
@@ -118,8 +117,8 @@ func (m *Manager) dyncfgCollectorExec(fn functions.Function) {
117 }
118 }
119
121 -func (m *Manager) dyncfgCollectorSeqExec(fn functions.Function) {
122 - cmd := getDyncfgCommand(fn)
120 +func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
121 + cmd := fn.Command()
122
123 switch cmd {
124 case dyncfg.CommandTest:
@@ -141,18 +140,18 @@ func (m *Manager) dyncfgCollectorSeqExec(fn functions.Function) {
140 case dyncfg.CommandUpdate:
141 m.dyncfgConfigUpdate(fn)
142 default:
144 - m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Name, cmd)
145 - m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Name, cmd)
143 + m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
144 + m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
145 }
146 }
147
149 -func (m *Manager) dyncfgConfigUserconfig(fn functions.Function) {
150 - cmd := getDyncfgCommand(fn)
148 +func (m *Manager) dyncfgConfigUserconfig(fn dyncfg.Function) {
149 + cmd := fn.Command()
150
152 - id := fn.Args[0]
153 - jn := "test"
154 - if len(fn.Args) > 2 {
155 - jn = fn.Args[2]
151 + id := fn.ID()
152 + jn := fn.JobName()
153 + if jn == "" {
154 + jn = "test"
155 }
156
157 mn, ok := m.extractModuleName(id)
@@ -184,10 +183,10 @@ func (m *Manager) dyncfgConfigUserconfig(fn functions.Function) {
183 m.dyncfgApi.SendYAML(fn, string(bs))
184 }
185
187 -func (m *Manager) dyncfgConfigTest(fn functions.Function) {
188 - cmd := getDyncfgCommand(fn)
186 +func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
187 + cmd := fn.Command()
188
190 - id := fn.Args[0]
189 + id := fn.ID()
190 mn, ok := m.extractModuleName(id)
191 if !ok {
192 m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
@@ -195,12 +194,12 @@ func (m *Manager) dyncfgConfigTest(fn functions.Function) {
194 return
195 }
196
198 - jn := "test"
199 - if len(fn.Args) > 2 {
200 - jn = fn.Args[2]
197 + jn := fn.JobName()
198 + if jn == "" {
199 + jn = "test"
200 }
201
203 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
202 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
203
204 if err := validateJobName(jn); err != nil {
205 m.Warningf("dyncfg: %s: module %s: unacceptable job name '%s': %v", cmd, mn, jn, err)
@@ -260,10 +259,10 @@ func (m *Manager) dyncfgConfigTest(fn functions.Function) {
259 m.dyncfgApi.SendCodef(fn, 200, "")
260 }
261
263 -func (m *Manager) dyncfgConfigSchema(fn functions.Function) {
264 - cmd := getDyncfgCommand(fn)
262 +func (m *Manager) dyncfgConfigSchema(fn dyncfg.Function) {
263 + cmd := fn.Command()
264
266 - id := fn.Args[0]
265 + id := fn.ID()
266 mn, ok := m.extractModuleName(id)
267 if !ok {
268 m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
@@ -278,7 +277,7 @@ func (m *Manager) dyncfgConfigSchema(fn functions.Function) {
277 return
278 }
279
281 - m.Infof("dyncfg: %s: %s module by user '%s'", cmd, mn, getFnSourceValue(fn, "user"))
280 + m.Infof("dyncfg: %s: %s module by user '%s'", cmd, mn, fn.User())
281
282 if mod.JobConfigSchema == "" {
283 m.Warningf("dyncfg: schema: module %s: schema not found", mn)
@@ -289,10 +288,10 @@ func (m *Manager) dyncfgConfigSchema(fn functions.Function) {
288 m.dyncfgApi.SendJSON(fn, mod.JobConfigSchema)
289 }
290
292 -func (m *Manager) dyncfgConfigGet(fn functions.Function) {
293 - cmd := getDyncfgCommand(fn)
291 +func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
292 + cmd := fn.Command()
293
295 - id := fn.Args[0]
294 + id := fn.ID()
295 mn, jn, ok := m.extractModuleJobName(id)
296 if !ok {
297 m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
@@ -307,7 +306,7 @@ func (m *Manager) dyncfgConfigGet(fn functions.Function) {
306 return
307 }
308
310 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
309 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
310
311 ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
312 if !ok {
@@ -341,10 +340,10 @@ func (m *Manager) dyncfgConfigGet(fn functions.Function) {
340 m.dyncfgApi.SendJSON(fn, string(bs))
341 }
342
344 -func (m *Manager) dyncfgConfigRestart(fn functions.Function) {
345 - cmd := getDyncfgCommand(fn)
343 +func (m *Manager) dyncfgConfigRestart(fn dyncfg.Function) {
344 + cmd := fn.Command()
345
347 - id := fn.Args[0]
346 + id := fn.ID()
347 mn, jn, ok := m.extractModuleJobName(id)
348 if !ok {
349 m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
@@ -381,7 +380,7 @@ func (m *Manager) dyncfgConfigRestart(fn functions.Function) {
380
381 m.retryingTasks.remove(ecfg.cfg)
382
384 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
383 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
384
385 if err := job.AutoDetection(); err != nil {
386 job.Cleanup()
@@ -403,10 +402,10 @@ func (m *Manager) dyncfgConfigRestart(fn functions.Function) {
402 m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
403 }
404
406 -func (m *Manager) dyncfgConfigEnable(fn functions.Function) {
407 - cmd := getDyncfgCommand(fn)
405 +func (m *Manager) dyncfgConfigEnable(fn dyncfg.Function) {
406 + cmd := fn.Command()
407
409 - id := fn.Args[0]
408 + id := fn.ID()
409 mn, jn, ok := m.extractModuleJobName(id)
410 if !ok {
411 m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
@@ -449,7 +448,7 @@ func (m *Manager) dyncfgConfigEnable(fn functions.Function) {
448 }
449
450 if ecfg.status == dyncfg.StatusDisabled {
452 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
451 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
452 }
453
454 m.retryingTasks.remove(ecfg.cfg)
@@ -482,10 +481,10 @@ func (m *Manager) dyncfgConfigEnable(fn functions.Function) {
481 m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
482 }
483
485 -func (m *Manager) dyncfgConfigDisable(fn functions.Function) {
486 - cmd := getDyncfgCommand(fn)
484 +func (m *Manager) dyncfgConfigDisable(fn dyncfg.Function) {
485 + cmd := fn.Command()
486
488 - id := fn.Args[0]
487 + id := fn.ID()
488 mn, jn, ok := m.extractModuleJobName(id)
489 if !ok {
490 m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
@@ -519,24 +518,24 @@ func (m *Manager) dyncfgConfigDisable(fn functions.Function) {
518
519 m.retryingTasks.remove(ecfg.cfg)
520
522 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
521 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
522
523 ecfg.status = dyncfg.StatusDisabled
524 m.dyncfgApi.SendCodef(fn, 200, "")
525 m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
526 }
527
529 -func (m *Manager) dyncfgConfigAdd(fn functions.Function) {
530 - cmd := getDyncfgCommand(fn)
528 +func (m *Manager) dyncfgConfigAdd(fn dyncfg.Function) {
529 + cmd := fn.Command()
530
532 - if len(fn.Args) < 3 {
533 - m.Warningf("dyncfg: %s: missing required arguments, want 3 got %d", cmd, len(fn.Args))
534 - m.dyncfgApi.SendCodef(fn, 400, "Missing required arguments. Need at least 3, but got %d.", len(fn.Args))
531 + if err := fn.ValidateArgs(3); err != nil {
532 + m.Warningf("dyncfg: %s: %v", cmd, err)
533 + m.dyncfgApi.SendCodef(fn, 400, "%v", err)
534 return
535 }
536
538 - id := fn.Args[0]
539 - jn := fn.Args[2]
537 + id := fn.ID()
538 + jn := fn.JobName()
539 mn, ok := m.extractModuleName(id)
540 if !ok {
541 m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
@@ -544,7 +543,7 @@ func (m *Manager) dyncfgConfigAdd(fn functions.Function) {
543 return
544 }
545
547 - if len(fn.Payload) == 0 {
546 + if !fn.HasPayload() {
547 m.Warningf("dyncfg: %s: module %s job %s missing configuration payload.", cmd, mn, jn)
548 m.dyncfgApi.SendCodef(fn, 400, "Missing configuration payload.")
549 return
@@ -571,7 +570,7 @@ func (m *Manager) dyncfgConfigAdd(fn functions.Function) {
570 return
571 }
572
574 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
573 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
574
575 if ecfg, ok := m.exposedConfigs.lookup(cfg); ok {
576 if scfg, ok := m.seenConfigs.lookup(ecfg.cfg); ok && isDyncfg(scfg.cfg) {
@@ -591,10 +590,10 @@ func (m *Manager) dyncfgConfigAdd(fn functions.Function) {
590 m.dyncfgCollectorJobCreate(ecfg.cfg, ecfg.status)
591 }
592
594 -func (m *Manager) dyncfgConfigRemove(fn functions.Function) {
595 - cmd := getDyncfgCommand(fn)
593 +func (m *Manager) dyncfgConfigRemove(fn dyncfg.Function) {
594 + cmd := fn.Command()
595
597 - id := fn.Args[0]
596 + id := fn.ID()
597 mn, jn, ok := m.extractModuleJobName(id)
598 if !ok {
599 m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
@@ -615,7 +614,7 @@ func (m *Manager) dyncfgConfigRemove(fn functions.Function) {
614 return
615 }
616
618 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
617 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
618
619 m.retryingTasks.remove(ecfg.cfg)
620 m.seenConfigs.remove(ecfg.cfg)
@@ -627,10 +626,10 @@ func (m *Manager) dyncfgConfigRemove(fn functions.Function) {
626 m.dyncfgJobRemove(ecfg.cfg)
627 }
628
630 -func (m *Manager) dyncfgConfigUpdate(fn functions.Function) {
631 - cmd := getDyncfgCommand(fn)
629 +func (m *Manager) dyncfgConfigUpdate(fn dyncfg.Function) {
630 + cmd := fn.Command()
631
633 - id := fn.Args[0]
632 + id := fn.ID()
633 mn, jn, ok := m.extractModuleJobName(id)
634 if !ok {
635 m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
@@ -676,7 +675,7 @@ func (m *Manager) dyncfgConfigUpdate(fn functions.Function) {
675 return
676 }
677
679 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, getFnSourceValue(fn, "user"))
678 + m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
679
680 m.exposedConfigs.remove(ecfg.cfg)
681 m.stopRunningJob(ecfg.cfg.FullName())
@@ -719,9 +718,9 @@ func (m *Manager) dyncfgConfigUpdate(fn functions.Function) {
718 m.dyncfgJobStatus(scfg.cfg, scfg.status)
719 }
720
722 -func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string, fn functions.Function) {
721 +func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string, fn dyncfg.Function) {
722 cfg.SetProvider("dyncfg")
724 - cfg.SetSource(fn.Source)
723 + cfg.SetSource(fn.Source())
724 cfg.SetSourceType("dyncfg")
725 cfg.SetModule(module)
726 cfg.SetName(name)
@@ -743,8 +742,8 @@ func (m *Manager) runRetryTask(ecfg *seenConfig, job *module.Job) {
742 go runRetryTask(ctx, m.addCh, ecfg.cfg)
743 }
744
746 -func userConfigFromPayload(cfg any, jobName string, fn functions.Function) ([]byte, error) {
747 - if err := unmarshalPayload(cfg, fn); err != nil {
745 +func userConfigFromPayload(cfg any, jobName string, fn dyncfg.Function) ([]byte, error) {
746 + if err := fn.UnmarshalPayload(cfg); err != nil {
747 return nil, err
748 }
749
@@ -769,18 +768,18 @@ func userConfigFromPayload(cfg any, jobName string, fn functions.Function) ([]by
768 return yaml.Marshal(v)
769 }
770
772 -func configFromPayload(fn functions.Function) (confgroup.Config, error) {
771 +func configFromPayload(fn dyncfg.Function) (confgroup.Config, error) {
772 var cfg confgroup.Config
773
775 - if fn.ContentType == "application/json" {
776 - if err := json.Unmarshal(fn.Payload, &cfg); err != nil {
774 + if fn.IsContentTypeJSON() {
775 + if err := json.Unmarshal(fn.Payload(), &cfg); err != nil {
776 return nil, err
777 }
778
779 return cfg.Clone()
780 }
781
783 - if err := yaml.Unmarshal(fn.Payload, &cfg); err != nil {
782 + if err := yaml.Unmarshal(fn.Payload(), &cfg); err != nil {
783 return nil, err
784 }
785
src/go/plugin/go.d/agent/jobmgr/dyncfg_vnode.go
+32 -34
@@ -3,7 +3,6 @@
3 package jobmgr
4
5 import (
6 - _ "embed"
6 "encoding/json"
7 "fmt"
8 "strings"
@@ -15,7 +14,6 @@ import (
14 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
16 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
18 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
17 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
18 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
19 )
@@ -75,8 +73,8 @@ func (m *Manager) dyncfgVnodeJobCreate(cfg *vnodes.VirtualNode, status dyncfg.St
73 })
74 }
75
78 -func (m *Manager) dyncfgVnodeExec(fn functions.Function) {
79 - cmd := dyncfg.Command(strings.ToLower(fn.Args[1]))
76 +func (m *Manager) dyncfgVnodeExec(fn dyncfg.Function) {
77 + cmd := fn.Command()
78
79 switch cmd {
80 case dyncfg.CommandUserconfig:
@@ -94,8 +92,8 @@ func (m *Manager) dyncfgVnodeExec(fn functions.Function) {
92 }
93 }
94
97 -func (m *Manager) dyncfgVnodeSeqExec(fn functions.Function) {
98 - cmd := dyncfg.Command(strings.ToLower(fn.Args[1]))
95 +func (m *Manager) dyncfgVnodeSeqExec(fn dyncfg.Function) {
96 + cmd := fn.Command()
97
98 switch cmd {
99 case dyncfg.CommandTest:
@@ -109,15 +107,15 @@ func (m *Manager) dyncfgVnodeSeqExec(fn functions.Function) {
107 case dyncfg.CommandRemove:
108 m.dyncfgVnodeRemove(fn)
109 default:
112 - m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Name, cmd)
113 - m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Name, cmd)
110 + m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
111 + m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
112 }
113 }
114
117 -func (m *Manager) dyncfgVnodeGet(fn functions.Function) {
115 +func (m *Manager) dyncfgVnodeGet(fn dyncfg.Function) {
116 cmd := dyncfg.CommandGet
117
120 - id := fn.Args[0]
118 + id := fn.ID()
119 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
120
121 cfg, ok := m.Vnodes[name]
@@ -137,18 +135,18 @@ func (m *Manager) dyncfgVnodeGet(fn functions.Function) {
135 m.dyncfgApi.SendJSON(fn, string(bs))
136 }
137
140 -func (m *Manager) dyncfgVnodeAdd(fn functions.Function) {
138 +func (m *Manager) dyncfgVnodeAdd(fn dyncfg.Function) {
139 cmd := dyncfg.CommandAdd
140
143 - if len(fn.Args) < 3 {
144 - m.Warningf("dyncfg: %s: missing required arguments, want 3 got %d", cmd, len(fn.Args))
145 - m.dyncfgApi.SendCodef(fn, 400, "Missing required arguments. Need at least 3, but got %d.", len(fn.Args))
141 + if err := fn.ValidateArgs(3); err != nil {
142 + m.Warningf("dyncfg: %s: %v", cmd, err)
143 + m.dyncfgApi.SendCodef(fn, 400, "%v", err)
144 return
145 }
146
149 - name := fn.Args[2]
147 + name := fn.JobName()
148
151 - if len(fn.Payload) == 0 {
149 + if !fn.HasPayload() {
150 m.Warningf("dyncfg: %s: vnode job %s missing configuration payload.", cmd, name)
151 m.dyncfgApi.SendCodef(fn, 400, "Missing configuration payload.")
152 return
@@ -193,10 +191,10 @@ func (m *Manager) dyncfgVnodeAdd(fn functions.Function) {
191 m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
192 }
193
196 -func (m *Manager) dyncfgVnodeRemove(fn functions.Function) {
194 +func (m *Manager) dyncfgVnodeRemove(fn dyncfg.Function) {
195 cmd := dyncfg.CommandRemove
196
199 - id := fn.Args[0]
197 + id := fn.ID()
198 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
199
200 vnode, ok := m.Vnodes[name]
@@ -223,16 +221,16 @@ func (m *Manager) dyncfgVnodeRemove(fn functions.Function) {
221 m.dyncfgApi.SendCodef(fn, 200, "")
222 }
223
226 -func (m *Manager) dyncfgVnodeTest(fn functions.Function) {
224 +func (m *Manager) dyncfgVnodeTest(fn dyncfg.Function) {
225 cmd := dyncfg.CommandTest
226
229 - if len(fn.Args) < 3 {
230 - m.Warningf("dyncfg: %s: missing required arguments, want 3 got %d", cmd, len(fn.Args))
231 - m.dyncfgApi.SendCodef(fn, 400, "Missing required arguments. Need at least 3, but got %d.", len(fn.Args))
227 + if err := fn.ValidateArgs(3); err != nil {
228 + m.Warningf("dyncfg: %s: %v", cmd, err)
229 + m.dyncfgApi.SendCodef(fn, 400, "%v", err)
230 return
231 }
232
235 - name := fn.Args[2]
233 + name := fn.JobName()
234
235 cfg, err := vnodeConfigFromPayload(fn)
236 if err != nil {
@@ -262,10 +260,10 @@ func (m *Manager) dyncfgVnodeTest(fn functions.Function) {
260 }
261 }
262
265 -func (m *Manager) dyncfgVnodeUpdate(fn functions.Function) {
263 +func (m *Manager) dyncfgVnodeUpdate(fn dyncfg.Function) {
264 cmd := dyncfg.CommandUpdate
265
268 - id := fn.Args[0]
266 + id := fn.ID()
267 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
268
269 orig, ok := m.Vnodes[name]
@@ -307,7 +305,7 @@ func (m *Manager) dyncfgVnodeUpdate(fn functions.Function) {
305 m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
306 }
307
310 -func (m *Manager) dyncfgVnodeUserconfig(fn functions.Function) {
308 +func (m *Manager) dyncfgVnodeUserconfig(fn dyncfg.Function) {
309 cmd := dyncfg.CommandUserconfig
310
311 bs, err := vnodeUserconfigFromPayload(fn)
@@ -348,34 +346,34 @@ func (m *Manager) verifyVnodeUnique(newCfg *vnodes.VirtualNode) error {
346 return nil
347 }
348
351 -func dyncfgUpdateVnodeConfig(cfg *vnodes.VirtualNode, name string, fn functions.Function) {
349 +func dyncfgUpdateVnodeConfig(cfg *vnodes.VirtualNode, name string, fn dyncfg.Function) {
350 cfg.SourceType = confgroup.TypeDyncfg
353 - cfg.Source = fn.Source
351 + cfg.Source = fn.Source()
352 cfg.Name = name
353 if cfg.Hostname == "" {
354 cfg.Hostname = name
355 }
356 }
357
360 -func vnodeConfigFromPayload(fn functions.Function) (*vnodes.VirtualNode, error) {
358 +func vnodeConfigFromPayload(fn dyncfg.Function) (*vnodes.VirtualNode, error) {
359 var cfg vnodes.VirtualNode
360
363 - if err := unmarshalPayload(&cfg, fn); err != nil {
361 + if err := fn.UnmarshalPayload(&cfg); err != nil {
362 return nil, err
363 }
364
365 return &cfg, nil
366 }
367
370 -func vnodeUserconfigFromPayload(fn functions.Function) ([]byte, error) {
368 +func vnodeUserconfigFromPayload(fn dyncfg.Function) ([]byte, error) {
369 cfg, err := vnodeConfigFromPayload(fn)
370 if err != nil {
371 return nil, err
372 }
373
376 - name := "test"
377 - if len(fn.Args) > 2 {
378 - name = fn.Args[2]
374 + name := fn.JobName()
375 + if name == "" {
376 + name = "test"
377 }
378
379 dyncfgUpdateVnodeConfig(cfg, name, fn)
src/go/plugin/go.d/agent/jobmgr/funcshandler.go
+2 -1
@@ -10,6 +10,7 @@ import (
10 "strings"
11
12 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
14 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
16 )
@@ -317,7 +318,7 @@ func (m *Manager) respondJSON(fn functions.Function, resp map[string]any) {
318 return
319 }
320
320 - m.dyncfgApi.SendJSONWithCode(fn, string(data), code)
321 + m.dyncfgApi.SendJSONWithCode(dyncfg.NewFunction(fn), string(data), code)
322 }
323
324 func parsePayload(raw []byte) map[string]any {
src/go/plugin/go.d/agent/jobmgr/manager.go
+5 -5
@@ -51,7 +51,7 @@ func New() *Manager {
51 started: make(chan struct{}),
52 addCh: make(chan confgroup.Config),
53 rmCh: make(chan confgroup.Config),
54 - dyncfgCh: make(chan functions.Function),
54 + dyncfgCh: make(chan dyncfg.Function),
55 dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.Stdout)),
56 }
57
@@ -96,7 +96,7 @@ type Manager struct {
96 //api dyncfgAPI
97 addCh chan confgroup.Config
98 rmCh chan confgroup.Config
99 - dyncfgCh chan functions.Function
99 + dyncfgCh chan dyncfg.Function
100
101 waitCfgOnOff string // block processing of discovered configs until "enable"/"disable" is received from Netdata
102
@@ -111,8 +111,8 @@ func (m *Manager) Run(ctx context.Context, in chan []*confgroup.Group) {
111 defer func() { m.cleanup(); m.Info("instance is stopped") }()
112 m.ctx = ctx
113
114 - m.FnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), m.dyncfgConfig)
115 - m.FnReg.RegisterPrefix("config", m.dyncfgVnodePrefixValue(), m.dyncfgConfig)
114 + m.FnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), m.dyncfgConfigHandler)
115 + m.FnReg.RegisterPrefix("config", m.dyncfgVnodePrefixValue(), m.dyncfgConfigHandler)
116
117 m.dyncfgVnodeModuleCreate()
118
@@ -293,7 +293,7 @@ func (m *Manager) addConfig(cfg confgroup.Config) {
293 m.dyncfgCollectorJobCreate(ecfg.cfg, ecfg.status)
294
295 if isTerminal || m.PluginName == "nodyncfg" { // FIXME: quick fix of TestAgent_Run (agent_test.go)
296 - m.dyncfgConfigEnable(functions.Function{Args: []string{m.dyncfgJobID(ecfg.cfg), "enable"}})
296 + m.dyncfgConfigEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(ecfg.cfg), "enable"}}))
297 } else {
298 m.waitCfgOnOff = ecfg.cfg.FullName()
299 }
src/go/plugin/go.d/agent/jobmgr/manager_test.go
+168 -168
@@ -23,10 +23,10 @@ func TestManager_Run(t *testing.T) {
23 return &runSim{
24 do: func(mgr *Manager, in chan []*confgroup.Group) {
25 sendConfGroup(in, cfg.Source(), cfg)
26 - mgr.dyncfgConfig(functions.Function{
26 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
27 UID: "1-enable",
28 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
29 - })
29 + }))
30
31 sendConfGroup(in, cfg.Source())
32 },
@@ -55,10 +55,10 @@ CONFIG test:collector:success:name delete
55 return &runSim{
56 do: func(mgr *Manager, in chan []*confgroup.Group) {
57 sendConfGroup(in, cfg.Source(), cfg)
58 - mgr.dyncfgConfig(functions.Function{
58 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
59 UID: "1-enable",
60 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
61 - })
61 + }))
62 },
63 wantDiscovered: []confgroup.Config{cfg},
64 wantSeen: []seenConfig{
@@ -85,10 +85,10 @@ CONFIG test:collector:fail:name delete
85 return &runSim{
86 do: func(mgr *Manager, in chan []*confgroup.Group) {
87 sendConfGroup(in, cfg.Source(), cfg)
88 - mgr.dyncfgConfig(functions.Function{
88 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
89 UID: "1-enable",
90 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
91 - })
91 + }))
92
93 sendConfGroup(in, cfg.Source())
94 },
@@ -115,10 +115,10 @@ CONFIG test:collector:fail:name delete
115 return &runSim{
116 do: func(mgr *Manager, in chan []*confgroup.Group) {
117 sendConfGroup(in, cfg.Source(), cfg)
118 - mgr.dyncfgConfig(functions.Function{
118 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
119 UID: "1-enable",
120 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
121 - })
121 + }))
122
123 sendConfGroup(in, cfg.Source())
124 },
@@ -147,10 +147,10 @@ CONFIG test:collector:success:name delete
147 return &runSim{
148 do: func(mgr *Manager, in chan []*confgroup.Group) {
149 sendConfGroup(in, cfg.Source(), cfg)
150 - mgr.dyncfgConfig(functions.Function{
150 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
151 UID: "1-enable",
152 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
153 - })
153 + }))
154
155 sendConfGroup(in, cfg.Source())
156 },
@@ -179,10 +179,10 @@ CONFIG test:collector:fail:name delete
179 return &runSim{
180 do: func(mgr *Manager, in chan []*confgroup.Group) {
181 sendConfGroup(in, cfg.Source(), cfg)
182 - mgr.dyncfgConfig(functions.Function{
182 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
183 UID: "1-enable",
184 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
185 - })
185 + }))
186
187 sendConfGroup(in, cfg.Source())
188 },
@@ -211,10 +211,10 @@ CONFIG test:collector:success:name delete
211 return &runSim{
212 do: func(mgr *Manager, in chan []*confgroup.Group) {
213 sendConfGroup(in, cfg.Source(), cfg)
214 - mgr.dyncfgConfig(functions.Function{
214 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
215 UID: "1-enable",
216 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
217 - })
217 + }))
218
219 sendConfGroup(in, cfg.Source())
220 },
@@ -245,22 +245,22 @@ CONFIG test:collector:fail:name delete
245 return &runSim{
246 do: func(mgr *Manager, in chan []*confgroup.Group) {
247 sendConfGroup(in, stockCfg.Source(), stockCfg)
248 - mgr.dyncfgConfig(functions.Function{
248 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
249 UID: "1-enable",
250 Args: []string{mgr.dyncfgJobID(stockCfg), "enable"},
251 - })
251 + }))
252
253 sendConfGroup(in, discCfg.Source(), discCfg)
254 - mgr.dyncfgConfig(functions.Function{
254 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
255 UID: "2-enable",
256 Args: []string{mgr.dyncfgJobID(discCfg), "enable"},
257 - })
257 + }))
258
259 sendConfGroup(in, userCfg.Source(), userCfg)
260 - mgr.dyncfgConfig(functions.Function{
260 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
261 UID: "3-enable",
262 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
263 - })
263 + }))
264 },
265 wantDiscovered: []confgroup.Config{
266 stockCfg,
@@ -314,22 +314,22 @@ CONFIG test:collector:fail:user status failed
314 return &runSim{
315 do: func(mgr *Manager, in chan []*confgroup.Group) {
316 sendConfGroup(in, stockCfg.Source(), stockCfg)
317 - mgr.dyncfgConfig(functions.Function{
317 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
318 UID: "1-enable",
319 Args: []string{mgr.dyncfgJobID(stockCfg), "enable"},
320 - })
320 + }))
321
322 sendConfGroup(in, discCfg.Source(), discCfg)
323 - mgr.dyncfgConfig(functions.Function{
323 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
324 UID: "2-enable",
325 Args: []string{mgr.dyncfgJobID(discCfg), "enable"},
326 - })
326 + }))
327
328 sendConfGroup(in, userCfg.Source(), userCfg)
329 - mgr.dyncfgConfig(functions.Function{
329 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
330 UID: "3-enable",
331 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
332 - })
332 + }))
333 },
334 wantDiscovered: []confgroup.Config{
335 stockCfg,
@@ -382,22 +382,22 @@ CONFIG test:collector:fail:name status failed
382 return &runSim{
383 do: func(mgr *Manager, in chan []*confgroup.Group) {
384 sendConfGroup(in, stockCfg.Source(), stockCfg)
385 - mgr.dyncfgConfig(functions.Function{
385 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
386 UID: "1-enable",
387 Args: []string{mgr.dyncfgJobID(stockCfg), "enable"},
388 - })
388 + }))
389
390 sendConfGroup(in, discCfg.Source(), discCfg)
391 - mgr.dyncfgConfig(functions.Function{
391 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
392 UID: "2-enable",
393 Args: []string{mgr.dyncfgJobID(discCfg), "enable"},
394 - })
394 + }))
395
396 sendConfGroup(in, userCfg.Source(), userCfg)
397 - mgr.dyncfgConfig(functions.Function{
397 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
398 UID: "3-enable",
399 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
400 - })
400 + }))
401
402 sendConfGroup(in, stockCfg.Source())
403 sendConfGroup(in, discCfg.Source())
@@ -446,10 +446,10 @@ CONFIG test:collector:fail:name delete
446 return &runSim{
447 do: func(mgr *Manager, in chan []*confgroup.Group) {
448 sendConfGroup(in, userCfg.Source(), userCfg)
449 - mgr.dyncfgConfig(functions.Function{
449 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
450 UID: "1-enable",
451 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
452 - })
452 + }))
453
454 sendConfGroup(in, discCfg.Source(), discCfg)
455 sendConfGroup(in, stockCfg.Source(), stockCfg)
@@ -489,10 +489,10 @@ CONFIG test:collector:fail:name status failed
489 return &runSim{
490 do: func(mgr *Manager, in chan []*confgroup.Group) {
491 sendConfGroup(in, userCfg.Source(), userCfg)
492 - mgr.dyncfgConfig(functions.Function{
492 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
493 UID: "1-enable",
494 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
495 - })
495 + }))
496
497 sendConfGroup(in, discCfg.Source(), discCfg)
498 sendConfGroup(in, stockCfg.Source(), stockCfg)
@@ -539,10 +539,10 @@ func TestManager_Run_Dyncfg_Get(t *testing.T) {
539
540 return &runSim{
541 do: func(mgr *Manager, _ chan []*confgroup.Group) {
542 - mgr.dyncfgConfig(functions.Function{
542 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
543 UID: "1-get",
544 Args: []string{mgr.dyncfgJobID(cfg), "get"},
545 - })
545 + }))
546 },
547 wantDiscovered: nil,
548 wantSeen: nil,
@@ -566,16 +566,16 @@ FUNCTION_RESULT_END
566
567 return &runSim{
568 do: func(mgr *Manager, _ chan []*confgroup.Group) {
569 - mgr.dyncfgConfig(functions.Function{
569 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
570 UID: "1-add",
571 Source: "type=dyncfg",
572 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
573 Payload: bs,
574 - })
575 - mgr.dyncfgConfig(functions.Function{
574 + }))
575 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
576 UID: "2-get",
577 Args: []string{mgr.dyncfgJobID(cfg), "get"},
578 - })
578 + }))
579 },
580 wantDiscovered: nil,
581 wantSeen: []seenConfig{
@@ -620,10 +620,10 @@ func TestManager_Run_Dyncfg_Userconfig(t *testing.T) {
620
621 return &runSim{
622 do: func(mgr *Manager, _ chan []*confgroup.Group) {
623 - mgr.dyncfgConfig(functions.Function{
623 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
624 UID: "1-userconfig",
625 Args: []string{mgr.dyncfgJobID(cfg), "userconfig"},
626 - })
626 + }))
627 },
628 wantDiscovered: nil,
629 wantSeen: nil,
@@ -647,10 +647,10 @@ FUNCTION_RESULT_END
647
648 return &runSim{
649 do: func(mgr *Manager, _ chan []*confgroup.Group) {
650 - mgr.dyncfgConfig(functions.Function{
650 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
651 UID: "1-userconfig",
652 Args: []string{mgr.dyncfgJobID(cfg), "userconfig"},
653 - })
653 + }))
654 },
655 wantDiscovered: nil,
656 wantSeen: nil,
@@ -684,12 +684,12 @@ func TestManager_Run_Dyncfg_Add(t *testing.T) {
684
685 return &runSim{
686 do: func(mgr *Manager, _ chan []*confgroup.Group) {
687 - mgr.dyncfgConfig(functions.Function{
687 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
688 UID: "1-add",
689 Source: "type=dyncfg",
690 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
691 Payload: []byte("{}"),
692 - })
692 + }))
693 },
694 wantDiscovered: nil,
695 wantSeen: []seenConfig{
@@ -716,12 +716,12 @@ CONFIG test:collector:success:test create accepted job /collectors/test/Jobs dyn
716
717 return &runSim{
718 do: func(mgr *Manager, _ chan []*confgroup.Group) {
719 - mgr.dyncfgConfig(functions.Function{
719 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
720 UID: "1-add",
721 Source: "type=dyncfg",
722 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
723 Payload: []byte("{}"),
724 - })
724 + }))
725 },
726 wantDiscovered: nil,
727 wantSeen: []seenConfig{
@@ -748,18 +748,18 @@ CONFIG test:collector:fail:test create accepted job /collectors/test/Jobs dyncfg
748
749 return &runSim{
750 do: func(mgr *Manager, _ chan []*confgroup.Group) {
751 - mgr.dyncfgConfig(functions.Function{
751 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
752 UID: "1-add",
753 Source: "type=dyncfg",
754 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
755 Payload: []byte("{}"),
756 - })
757 - mgr.dyncfgConfig(functions.Function{
756 + }))
757 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
758 UID: "2-add",
759 Source: "type=dyncfg",
760 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
761 Payload: []byte("{}"),
762 - })
762 + }))
763 },
764 wantDiscovered: nil,
765 wantSeen: []seenConfig{
@@ -806,10 +806,10 @@ func TestManager_Run_Dyncfg_Enable(t *testing.T) {
806
807 return &runSim{
808 do: func(mgr *Manager, _ chan []*confgroup.Group) {
809 - mgr.dyncfgConfig(functions.Function{
809 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
810 UID: "1-enable",
811 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
812 - })
812 + }))
813 },
814 wantDiscovered: nil,
815 wantSeen: nil,
@@ -830,16 +830,16 @@ FUNCTION_RESULT_END
830
831 return &runSim{
832 do: func(mgr *Manager, _ chan []*confgroup.Group) {
833 - mgr.dyncfgConfig(functions.Function{
833 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
834 UID: "1-add",
835 Source: "type=dyncfg",
836 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
837 Payload: []byte("{}"),
838 - })
839 - mgr.dyncfgConfig(functions.Function{
838 + }))
839 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
840 UID: "2-enable",
841 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
842 - })
842 + }))
843 },
844 wantDiscovered: nil,
845 wantSeen: []seenConfig{
@@ -872,20 +872,20 @@ CONFIG test:collector:success:test status running
872
873 return &runSim{
874 do: func(mgr *Manager, _ chan []*confgroup.Group) {
875 - mgr.dyncfgConfig(functions.Function{
875 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
876 UID: "1-add",
877 Source: "type=dyncfg",
878 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
879 Payload: []byte("{}"),
880 - })
881 - mgr.dyncfgConfig(functions.Function{
880 + }))
881 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
882 UID: "2-enable",
883 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
884 - })
885 - mgr.dyncfgConfig(functions.Function{
884 + }))
885 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
886 UID: "3-enable",
887 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
888 - })
888 + }))
889 },
890 wantDiscovered: nil,
891 wantSeen: []seenConfig{
@@ -924,16 +924,16 @@ CONFIG test:collector:success:test status running
924
925 return &runSim{
926 do: func(mgr *Manager, _ chan []*confgroup.Group) {
927 - mgr.dyncfgConfig(functions.Function{
927 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
928 UID: "1-add",
929 Source: "type=dyncfg",
930 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
931 Payload: []byte("{}"),
932 - })
933 - mgr.dyncfgConfig(functions.Function{
932 + }))
933 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
934 UID: "2-enable",
935 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
936 - })
936 + }))
937 },
938 wantDiscovered: nil,
939 wantSeen: []seenConfig{
@@ -966,20 +966,20 @@ CONFIG test:collector:fail:test status failed
966
967 return &runSim{
968 do: func(mgr *Manager, _ chan []*confgroup.Group) {
969 - mgr.dyncfgConfig(functions.Function{
969 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
970 UID: "1-add",
971 Source: "type=dyncfg",
972 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
973 Payload: []byte("{}"),
974 - })
975 - mgr.dyncfgConfig(functions.Function{
974 + }))
975 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
976 UID: "2-enable",
977 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
978 - })
979 - mgr.dyncfgConfig(functions.Function{
978 + }))
979 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
980 UID: "3-enable",
981 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
982 - })
982 + }))
983 },
984 wantDiscovered: nil,
985 wantSeen: []seenConfig{
@@ -1032,10 +1032,10 @@ func TestManager_Run_Dyncfg_Disable(t *testing.T) {
1032
1033 return &runSim{
1034 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1035 - mgr.dyncfgConfig(functions.Function{
1035 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1036 UID: "1-disable",
1037 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1038 - })
1038 + }))
1039 },
1040 wantDiscovered: nil,
1041 wantSeen: nil,
@@ -1056,16 +1056,16 @@ FUNCTION_RESULT_END
1056
1057 return &runSim{
1058 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1059 - mgr.dyncfgConfig(functions.Function{
1059 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1060 UID: "1-add",
1061 Source: "type=dyncfg",
1062 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1063 Payload: []byte("{}"),
1064 - })
1065 - mgr.dyncfgConfig(functions.Function{
1064 + }))
1065 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1066 UID: "2-disable",
1067 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1068 - })
1068 + }))
1069 },
1070 wantDiscovered: nil,
1071 wantSeen: []seenConfig{
@@ -1098,20 +1098,20 @@ CONFIG test:collector:success:test status disabled
1098
1099 return &runSim{
1100 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1101 - mgr.dyncfgConfig(functions.Function{
1101 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1102 UID: "1-add",
1103 Source: "type=dyncfg",
1104 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1105 Payload: []byte("{}"),
1106 - })
1107 - mgr.dyncfgConfig(functions.Function{
1106 + }))
1107 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1108 UID: "2-disable",
1109 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1110 - })
1111 - mgr.dyncfgConfig(functions.Function{
1110 + }))
1111 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1112 UID: "3-disable",
1113 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1114 - })
1114 + }))
1115 },
1116 wantDiscovered: nil,
1117 wantSeen: []seenConfig{
@@ -1150,16 +1150,16 @@ CONFIG test:collector:success:test status disabled
1150
1151 return &runSim{
1152 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1153 - mgr.dyncfgConfig(functions.Function{
1153 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1154 UID: "1-add",
1155 Source: "type=dyncfg",
1156 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1157 Payload: []byte("{}"),
1158 - })
1159 - mgr.dyncfgConfig(functions.Function{
1158 + }))
1159 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1160 UID: "2-disable",
1161 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1162 - })
1162 + }))
1163 },
1164 wantDiscovered: nil,
1165 wantSeen: []seenConfig{
@@ -1192,20 +1192,20 @@ CONFIG test:collector:fail:test status disabled
1192
1193 return &runSim{
1194 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1195 - mgr.dyncfgConfig(functions.Function{
1195 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1196 UID: "1-add",
1197 Source: "type=dyncfg",
1198 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1199 Payload: []byte("{}"),
1200 - })
1201 - mgr.dyncfgConfig(functions.Function{
1200 + }))
1201 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1202 UID: "2-disable",
1203 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1204 - })
1205 - mgr.dyncfgConfig(functions.Function{
1204 + }))
1205 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1206 UID: "3-disable",
1207 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1208 - })
1208 + }))
1209 },
1210 wantDiscovered: nil,
1211 wantSeen: []seenConfig{
@@ -1258,10 +1258,10 @@ func TestManager_Run_Dyncfg_Restart(t *testing.T) {
1258
1259 return &runSim{
1260 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1261 - mgr.dyncfgConfig(functions.Function{
1261 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1262 UID: "1-restart",
1263 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1264 - })
1264 + }))
1265 },
1266 wantDiscovered: nil,
1267 wantSeen: nil,
@@ -1282,16 +1282,16 @@ FUNCTION_RESULT_END
1282
1283 return &runSim{
1284 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1285 - mgr.dyncfgConfig(functions.Function{
1285 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1286 UID: "1-add",
1287 Source: "type=dyncfg",
1288 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1289 Payload: []byte("{}"),
1290 - })
1291 - mgr.dyncfgConfig(functions.Function{
1290 + }))
1291 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1292 UID: "2-restart",
1293 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1294 - })
1294 + }))
1295 },
1296 wantDiscovered: nil,
1297 wantSeen: []seenConfig{
@@ -1324,20 +1324,20 @@ CONFIG test:collector:success:test status accepted
1324
1325 return &runSim{
1326 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1327 - mgr.dyncfgConfig(functions.Function{
1327 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1328 UID: "1-add",
1329 Source: "type=dyncfg",
1330 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1331 Payload: []byte("{}"),
1332 - })
1333 - mgr.dyncfgConfig(functions.Function{
1332 + }))
1333 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1334 UID: "2-enable",
1335 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1336 - })
1337 - mgr.dyncfgConfig(functions.Function{
1336 + }))
1337 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1338 UID: "3-restart",
1339 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1340 - })
1340 + }))
1341 },
1342 wantDiscovered: nil,
1343 wantSeen: []seenConfig{
@@ -1376,20 +1376,20 @@ CONFIG test:collector:success:test status running
1376
1377 return &runSim{
1378 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1379 - mgr.dyncfgConfig(functions.Function{
1379 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1380 UID: "1-add",
1381 Source: "type=dyncfg",
1382 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1383 Payload: []byte("{}"),
1384 - })
1385 - mgr.dyncfgConfig(functions.Function{
1384 + }))
1385 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1386 UID: "2-disable",
1387 Args: []string{mgr.dyncfgJobID(cfg), "disable"},
1388 - })
1389 - mgr.dyncfgConfig(functions.Function{
1388 + }))
1389 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1390 UID: "3-restart",
1391 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1392 - })
1392 + }))
1393 },
1394 wantDiscovered: nil,
1395 wantSeen: []seenConfig{
@@ -1428,24 +1428,24 @@ CONFIG test:collector:success:test status disabled
1428
1429 return &runSim{
1430 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1431 - mgr.dyncfgConfig(functions.Function{
1431 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1432 UID: "1-add",
1433 Source: "type=dyncfg",
1434 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1435 Payload: []byte("{}"),
1436 - })
1437 - mgr.dyncfgConfig(functions.Function{
1436 + }))
1437 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1438 UID: "2-enable",
1439 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1440 - })
1441 - mgr.dyncfgConfig(functions.Function{
1440 + }))
1441 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1442 UID: "3-restart",
1443 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1444 - })
1445 - mgr.dyncfgConfig(functions.Function{
1444 + }))
1445 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1446 UID: "4-restart",
1447 Args: []string{mgr.dyncfgJobID(cfg), "restart"},
1448 - })
1448 + }))
1449 },
1450 wantDiscovered: nil,
1451 wantSeen: []seenConfig{
@@ -1504,10 +1504,10 @@ func TestManager_Run_Dyncfg_Remove(t *testing.T) {
1504
1505 return &runSim{
1506 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1507 - mgr.dyncfgConfig(functions.Function{
1507 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1508 UID: "1-remove",
1509 Args: []string{mgr.dyncfgJobID(cfg), "remove"},
1510 - })
1510 + }))
1511 },
1512 wantDiscovered: nil,
1513 wantSeen: nil,
@@ -1531,35 +1531,35 @@ FUNCTION_RESULT_END
1531 return &runSim{
1532 do: func(mgr *Manager, in chan []*confgroup.Group) {
1533 sendConfGroup(in, stockCfg.Source(), stockCfg)
1534 - mgr.dyncfgConfig(functions.Function{
1534 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1535 UID: "1-enable",
1536 Args: []string{mgr.dyncfgJobID(stockCfg), "enable"},
1537 - })
1537 + }))
1538
1539 sendConfGroup(in, userCfg.Source(), userCfg)
1540 - mgr.dyncfgConfig(functions.Function{
1540 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1541 UID: "2-enable",
1542 Args: []string{mgr.dyncfgJobID(userCfg), "enable"},
1543 - })
1543 + }))
1544
1545 sendConfGroup(in, discCfg.Source(), discCfg)
1546 - mgr.dyncfgConfig(functions.Function{
1546 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1547 UID: "3-enable",
1548 Args: []string{mgr.dyncfgJobID(discCfg), "enable"},
1549 - })
1549 + }))
1550
1551 - mgr.dyncfgConfig(functions.Function{
1551 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1552 UID: "1-remove",
1553 Args: []string{mgr.dyncfgJobID(stockCfg), "remove"},
1554 - })
1555 - mgr.dyncfgConfig(functions.Function{
1554 + }))
1555 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1556 UID: "2-remove",
1557 Args: []string{mgr.dyncfgJobID(userCfg), "remove"},
1558 - })
1559 - mgr.dyncfgConfig(functions.Function{
1558 + }))
1559 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1560 UID: "3-remove",
1561 Args: []string{mgr.dyncfgJobID(discCfg), "remove"},
1562 - })
1562 + }))
1563 },
1564 wantDiscovered: []confgroup.Config{
1565 stockCfg,
@@ -1623,16 +1623,16 @@ FUNCTION_RESULT_END
1623
1624 return &runSim{
1625 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1626 - mgr.dyncfgConfig(functions.Function{
1626 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1627 UID: "1-add",
1628 Source: "type=dyncfg",
1629 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1630 Payload: []byte("{}"),
1631 - })
1632 - mgr.dyncfgConfig(functions.Function{
1631 + }))
1632 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1633 UID: "2-remove",
1634 Args: []string{mgr.dyncfgJobID(cfg), "remove"},
1635 - })
1635 + }))
1636 },
1637 wantDiscovered: nil,
1638 wantSeen: nil,
@@ -1661,20 +1661,20 @@ CONFIG test:collector:success:test delete
1661
1662 return &runSim{
1663 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1664 - mgr.dyncfgConfig(functions.Function{
1664 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1665 UID: "1-add",
1666 Source: "type=dyncfg",
1667 Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
1668 Payload: []byte("{}"),
1669 - })
1670 - mgr.dyncfgConfig(functions.Function{
1669 + }))
1670 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1671 UID: "2-enable",
1672 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1673 - })
1674 - mgr.dyncfgConfig(functions.Function{
1673 + }))
1674 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1675 UID: "3-remove",
1676 Args: []string{mgr.dyncfgJobID(cfg), "remove"},
1677 - })
1677 + }))
1678 },
1679 wantDiscovered: nil,
1680 wantSeen: nil,
@@ -1723,11 +1723,11 @@ func TestManager_Run_Dyncfg_Update(t *testing.T) {
1723
1724 return &runSim{
1725 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1726 - mgr.dyncfgConfig(functions.Function{
1726 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1727 UID: "1-update",
1728 Args: []string{mgr.dyncfgJobID(cfg), "update"},
1729 Payload: []byte("{}"),
1730 - })
1730 + }))
1731 },
1732 wantDiscovered: nil,
1733 wantSeen: nil,
@@ -1753,22 +1753,22 @@ FUNCTION_RESULT_END
1753
1754 return &runSim{
1755 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1756 - mgr.dyncfgConfig(functions.Function{
1756 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1757 UID: "1-add",
1758 Source: "type=dyncfg",
1759 Args: []string{mgr.dyncfgModID(origCfg.Module()), "add", origCfg.Name()},
1760 Payload: origBs,
1761 - })
1762 - mgr.dyncfgConfig(functions.Function{
1761 + }))
1762 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1763 UID: "2-enable",
1764 Args: []string{mgr.dyncfgJobID(origCfg), "enable"},
1765 - })
1766 - mgr.dyncfgConfig(functions.Function{
1765 + }))
1766 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1767 UID: "3-update",
1768 Source: "type=dyncfg",
1769 Args: []string{mgr.dyncfgJobID(origCfg), "update"},
1770 Payload: updBs,
1771 - })
1771 + }))
1772 },
1773 wantDiscovered: nil,
1774 wantSeen: []seenConfig{
@@ -1812,22 +1812,22 @@ CONFIG test:collector:success:test status running
1812
1813 return &runSim{
1814 do: func(mgr *Manager, _ chan []*confgroup.Group) {
1815 - mgr.dyncfgConfig(functions.Function{
1815 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1816 UID: "1-add",
1817 Source: "type=dyncfg",
1818 Args: []string{mgr.dyncfgModID(origCfg.Module()), "add", origCfg.Name()},
1819 Payload: origBs,
1820 - })
1821 - mgr.dyncfgConfig(functions.Function{
1820 + }))
1821 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1822 UID: "2-disable",
1823 Args: []string{mgr.dyncfgJobID(origCfg), "disable"},
1824 - })
1825 - mgr.dyncfgConfig(functions.Function{
1824 + }))
1825 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1826 UID: "3-update",
1827 Source: "type=dyncfg",
1828 Args: []string{mgr.dyncfgJobID(origCfg), "update"},
1829 Payload: updBs,
1830 - })
1830 + }))
1831 },
1832 wantDiscovered: nil,
1833 wantSeen: []seenConfig{
@@ -1936,10 +1936,10 @@ func TestManager_Run_FunctionOnly(t *testing.T) {
1936 return &runSim{
1937 do: func(mgr *Manager, in chan []*confgroup.Group) {
1938 sendConfGroup(in, cfg.Source(), cfg)
1939 - mgr.dyncfgConfig(functions.Function{
1939 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1940 UID: "1-enable",
1941 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1942 - })
1942 + }))
1943 },
1944 wantDiscovered: []confgroup.Config{cfg},
1945 wantSeen: []seenConfig{
@@ -1968,10 +1968,10 @@ CONFIG test:collector:nofuncs:test status failed
1968 return &runSim{
1969 do: func(mgr *Manager, in chan []*confgroup.Group) {
1970 sendConfGroup(in, cfg.Source(), cfg)
1971 - mgr.dyncfgConfig(functions.Function{
1971 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
1972 UID: "1-enable",
1973 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1974 - })
1974 + }))
1975 },
1976 wantDiscovered: []confgroup.Config{cfg},
1977 wantSeen: []seenConfig{
@@ -2000,10 +2000,10 @@ CONFIG test:collector:withfuncs:test status running
2000 return &runSim{
2001 do: func(mgr *Manager, in chan []*confgroup.Group) {
2002 sendConfGroup(in, cfg.Source(), cfg)
2003 - mgr.dyncfgConfig(functions.Function{
2003 + mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
2004 UID: "1-enable",
2005 Args: []string{mgr.dyncfgJobID(cfg), "enable"},
2006 - })
2006 + }))
2007 },
2008 wantDiscovered: []confgroup.Config{cfg},
2009 wantSeen: []seenConfig{
src/go/plugin/go.d/agent/setup.go
+5 -2
@@ -13,6 +13,7 @@ import (
13 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/dummy"
14 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/file"
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
17 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
18 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
19
@@ -75,7 +76,7 @@ func (a *Agent) loadEnabledModules(cfg config) module.Registry {
76 return enabled
77 }
78
78 -func (a *Agent) buildDiscoveryConf(enabled module.Registry) discovery.Config {
79 +func (a *Agent) buildDiscoveryConf(enabled module.Registry, fnReg functions.Registry) discovery.Config {
80 a.Info("building discovery config")
81
82 reg := confgroup.Registry{}
@@ -142,7 +143,9 @@ func (a *Agent) buildDiscoveryConf(enabled module.Registry) discovery.Config {
143 Names: dummyPaths,
144 }
145 cfg.SD = sd.Config{
145 - ConfDir: a.ServiceDiscoveryConfigDir,
146 + ConfigDefaults: reg,
147 + ConfDir: a.ServiceDiscoveryConfigDir,
148 + FnReg: fnReg,
149 }
150 }
151
src/go/plugin/go.d/config/go.d/sd/docker.conf
-1
@@ -5,7 +5,6 @@ name: 'docker'
5 discover:
6 - discoverer: docker
7 docker:
8 - tags: "unknown"
8 address: "unix:///var/run/docker.sock"
9
10 services:
src/go/plugin/go.d/config/go.d/sd/net_listeners.conf
+3 -4
@@ -2,10 +2,9 @@ disabled: no
2
3 name: 'network listeners'
4
5 -discover:
6 - - discoverer: net_listeners
7 - net_listeners:
8 - tags: "unknown"
5 +
6 +discoverer:
7 + net_listeners: {}
8
9 services:
10 - id: "activemq"
src/go/plugin/go.d/config/go.d/sd/snmp.conf
+1 -1
@@ -70,7 +70,7 @@ services:
70 hostname: {{ .IPAddress }}
71 options:
72 version: {{ .Credential.Version }}
73 - {{- if eq .Credential.Version "1" "2" }}
73 + {{- if eq .Credential.Version "1" "2" "2c" }}
74 community: {{ .Credential.Community }}
75 {{- else }}
76 user: