├── pipeline ├── model │ ├── base_test.go │ ├── tags_test.go │ ├── config.go │ ├── base.go │ ├── group.go │ ├── tags.go │ ├── selector.go │ └── selector_test.go ├── export │ ├── cache.go │ ├── manager_test.go │ ├── manager.go │ └── export.go ├── discovery │ ├── cache.go │ ├── sim_test.go │ ├── manager.go │ ├── kubernetes │ │ ├── sim_test.go │ │ ├── kubernetes_test.go │ │ ├── service.go │ │ ├── kubernetes.go │ │ ├── pod.go │ │ ├── service_test.go │ │ └── pod_test.go │ └── manager_test.go ├── tag │ ├── sim_test.go │ ├── config.go │ ├── tag.go │ └── tag_test.go ├── build │ ├── sim_test.go │ ├── config.go │ ├── build.go │ └── build_test.go ├── sim_test.go ├── pipeline.go └── pipeline_test.go ├── .github ├── CODEOWNERS ├── workflows │ ├── reviewdog.yml │ └── test_and_deploy.yml └── dependabot.yml ├── Dockerfile ├── .yamllint.yml ├── manager ├── config │ ├── provider │ │ ├── file │ │ │ ├── provider_test.go │ │ │ └── provider.go │ │ └── kubernetes │ │ │ ├── sim_test.go │ │ │ ├── provider.go │ │ │ └── provider_test.go │ └── config.go ├── sim_test.go ├── manager_test.go └── manager.go ├── pkg ├── log │ └── log.go ├── k8s │ └── clientset.go └── funcmap │ ├── funcmap_test.go │ └── funcmap.go ├── LICENSE ├── go.mod ├── cmd └── sd │ └── main.go ├── README.md └── go.sum /pipeline/model/base_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | -------------------------------------------------------------------------------- /pipeline/model/tags_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ilyam8 2 | 3 | # CI/CD 4 | .github/ @ilyam8 5 | 6 | # Documentation 7 | *.md @ilyam8 8 | -------------------------------------------------------------------------------- /pipeline/model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Config struct { 4 | Tags Tags 5 | Conf string 6 | Stale bool 7 | } 8 | -------------------------------------------------------------------------------- /pipeline/model/base.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Base struct { 4 | tags Tags 5 | } 6 | 7 | func (b *Base) Tags() Tags { 8 | if b.tags == nil { 9 | b.tags = NewTags() 10 | } 11 | return b.tags 12 | } 13 | -------------------------------------------------------------------------------- /pipeline/model/group.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Target interface { 4 | Hash() uint64 5 | Tags() Tags 6 | TUID() string 7 | } 8 | 9 | type Group interface { 10 | Targets() []Target 11 | Source() string 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.5-alpine as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o agent_sd github.com/netdata/sd/cmd/sd 8 | 9 | FROM alpine:3.21.2 10 | 11 | COPY --from=builder /app/agent_sd /app/ 12 | 13 | ENTRYPOINT ["/app/agent_sd"] 14 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | # extends https://yamllint.readthedocs.io/en/stable/configuration.html#default-configuration 2 | extends: default 3 | 4 | #yaml-files: 5 | # - 'dir/*.conf' 6 | # - 'dir/dir/*.conf' 7 | 8 | rules: 9 | document-start: disable 10 | truthy: 11 | check-keys: false 12 | line-length: 13 | max: 120 14 | -------------------------------------------------------------------------------- /manager/config/provider/file/provider_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import "testing" 4 | 5 | // TODO: tech debt 6 | func TestNewProvider(t *testing.T) { 7 | 8 | } 9 | 10 | // TODO: tech debt 11 | func TestProvider_Configs(t *testing.T) { 12 | 13 | } 14 | 15 | // TODO: tech debt 16 | func TestProvider_Run(t *testing.T) { 17 | 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | jobs: 4 | yamllint: 5 | name: yamllint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | - name: Run yamllint 11 | uses: reviewdog/action-yamllint@v1 12 | with: 13 | github_token: ${{ secrets.GITHUB_TOKEN }} 14 | reporter: github-pr-check 15 | -------------------------------------------------------------------------------- /pipeline/export/cache.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "github.com/netdata/sd/pipeline/model" 5 | ) 6 | 7 | type cache map[string]int 8 | 9 | func (c cache) put(cfg model.Config) (changed bool) { 10 | count, ok := c[cfg.Conf] 11 | // add 12 | if !cfg.Stale { 13 | c[cfg.Conf]++ 14 | return !ok 15 | } 16 | // remove 17 | if !ok { 18 | return false 19 | } 20 | if count--; count > 0 { 21 | c[cfg.Conf] = count 22 | return false 23 | } 24 | delete(c, cfg.Conf) 25 | return true 26 | } 27 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/mattn/go-isatty" 8 | "github.com/rs/zerolog" 9 | ) 10 | 11 | var ( 12 | isTerminal = isatty.IsTerminal(os.Stdout.Fd()) 13 | Output io.Writer = os.Stderr 14 | ) 15 | 16 | func init() { 17 | zerolog.TimeFieldFormat = "2006-01-02 15:04:05" 18 | } 19 | 20 | func New(name string) zerolog.Logger { 21 | if isTerminal { 22 | return zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}). 23 | With(). 24 | Timestamp(). 25 | Str("component", name). 26 | Logger() 27 | } 28 | return zerolog.New(Output). 29 | With(). 30 | Str("component", name). 31 | Timestamp(). 32 | Logger() 33 | } 34 | -------------------------------------------------------------------------------- /manager/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/netdata/sd/pipeline/build" 5 | "github.com/netdata/sd/pipeline/discovery" 6 | "github.com/netdata/sd/pipeline/export" 7 | "github.com/netdata/sd/pipeline/tag" 8 | 9 | "github.com/ilyam8/hashstructure" 10 | ) 11 | 12 | type Config struct { 13 | Pipeline *PipelineConfig 14 | Source string 15 | } 16 | 17 | type PipelineConfig struct { 18 | Name string `yaml:"name"` 19 | Discovery discovery.Config `yaml:"discovery"` 20 | Tag tag.Config `yaml:"tag"` 21 | Build build.Config `yaml:"build"` 22 | Export export.Config `yaml:"export"` 23 | } 24 | 25 | func (c PipelineConfig) Hash() uint64 { hash, _ := hashstructure.Hash(c, nil); return hash } 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" 13 | directory: / 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | # Check for updates to GitHub Actions every weekday 20 | interval: "daily" 21 | -------------------------------------------------------------------------------- /pipeline/discovery/cache.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/netdata/sd/pipeline/model" 7 | ) 8 | 9 | type cache struct { 10 | mu sync.RWMutex 11 | items map[string]model.Group 12 | } 13 | 14 | func newCache() *cache { 15 | return &cache{ 16 | mu: sync.RWMutex{}, 17 | items: make(map[string]model.Group), 18 | } 19 | } 20 | 21 | func (c *cache) update(groups []model.Group) { 22 | for _, group := range groups { 23 | if group != nil { 24 | c.items[group.Source()] = group 25 | } 26 | } 27 | } 28 | 29 | func (c *cache) reset() { 30 | for key := range c.items { 31 | delete(c.items, key) 32 | } 33 | } 34 | 35 | func (c *cache) asList() []model.Group { 36 | groups := make([]model.Group, 0, len(c.items)) 37 | for _, group := range c.items { 38 | groups = append(groups, group) 39 | } 40 | return groups 41 | } 42 | -------------------------------------------------------------------------------- /pipeline/tag/sim_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type ( 14 | tagSim struct { 15 | cfg Config 16 | invalid bool 17 | inputs []tagSimInput 18 | } 19 | tagSimInput struct { 20 | desc string 21 | target mockTarget 22 | expectedTags model.Tags 23 | } 24 | ) 25 | 26 | func (sim tagSim) run(t *testing.T) { 27 | mgr, err := New(sim.cfg) 28 | 29 | if sim.invalid { 30 | require.Error(t, err) 31 | return 32 | } 33 | 34 | require.NoError(t, err) 35 | require.NotNil(t, mgr) 36 | 37 | if len(sim.inputs) == 0 { 38 | return 39 | } 40 | 41 | for i, input := range sim.inputs { 42 | name := fmt.Sprintf("input:'%s'[%d], target:'%s', expected tags:'%s'", 43 | input.desc, i+1, input.target, input.expectedTags) 44 | 45 | mgr.Tag(input.target) 46 | assert.Equalf(t, input.expectedTags, input.target.Tags(), name) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pipeline/build/sim_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type ( 14 | buildSim struct { 15 | cfg Config 16 | invalid bool 17 | inputs []buildSimInput 18 | } 19 | buildSimInput struct { 20 | desc string 21 | target mockTarget 22 | expectedCfgs []model.Config 23 | } 24 | ) 25 | 26 | func (sim buildSim) run(t *testing.T) { 27 | mgr, err := New(sim.cfg) 28 | 29 | if sim.invalid { 30 | require.Error(t, err) 31 | return 32 | } 33 | 34 | require.NoError(t, err) 35 | require.NotNil(t, mgr) 36 | 37 | if len(sim.inputs) == 0 { 38 | return 39 | } 40 | 41 | for i, input := range sim.inputs { 42 | name := fmt.Sprintf("input:'%s'[%d], target:'%s', expected configs:'%v'", 43 | input.desc, i+1, input.target, input.expectedCfgs) 44 | 45 | actual := mgr.Build(input.target) 46 | assert.Equalf(t, input.expectedCfgs, actual, name) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 netdata 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pipeline/export/manager_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/netdata/sd/pipeline/model" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestManager_Export(t *testing.T) { 15 | e1, e2, e3 := &mockExporter{}, &mockExporter{}, &mockExporter{} 16 | mgr := &Manager{exporters: []exporter{e1, e2, e3}} 17 | out := make(chan []model.Config) 18 | wantCfgs := []model.Config{{Conf: "1"}, {Conf: "2"}, {Conf: "2"}} 19 | 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | defer cancel() 22 | 23 | var wg sync.WaitGroup 24 | 25 | wg.Add(1) 26 | go func() { 27 | defer wg.Done() 28 | mgr.Export(ctx, out) 29 | }() 30 | 31 | const timeout = time.Second * 2 32 | tk := time.NewTicker(timeout) 33 | defer tk.Stop() 34 | 35 | select { 36 | case out <- wantCfgs: 37 | case <-tk.C: 38 | t.Errorf("exporting timed out in %s", timeout) 39 | return 40 | } 41 | 42 | time.Sleep(time.Second) 43 | cancel() 44 | wg.Wait() 45 | 46 | assert.Equal(t, wantCfgs, e1.seen) 47 | assert.Equal(t, wantCfgs, e2.seen) 48 | assert.Equal(t, wantCfgs, e3.seen) 49 | } 50 | 51 | type mockExporter struct { 52 | seen []model.Config 53 | } 54 | 55 | func (e *mockExporter) Export(ctx context.Context, out <-chan []model.Config) { 56 | select { 57 | case <-ctx.Done(): 58 | case cfgs := <-out: 59 | e.seen = append(e.seen, cfgs...) 60 | } 61 | <-ctx.Done() 62 | } 63 | -------------------------------------------------------------------------------- /pipeline/tag/config.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type ( 9 | Config []RuleConfig // mandatory, at least 1 10 | RuleConfig struct { 11 | Name string `yaml:"name"` 12 | Selector string `yaml:"selector"` // mandatory 13 | Tags string `yaml:"tags"` // mandatory 14 | Match []MatchConfig `yaml:"match"` // mandatory, at least 1 15 | } 16 | MatchConfig struct { 17 | Selector string `yaml:"selector"` // optional 18 | Tags string `yaml:"tags"` // mandatory 19 | Expr string `yaml:"expr"` // mandatory 20 | } 21 | ) 22 | 23 | func validateConfig(cfg Config) error { 24 | if len(cfg) == 0 { 25 | return errors.New("empty config, need least 1 rule") 26 | } 27 | for i, rule := range cfg { 28 | if rule.Selector == "" { 29 | return fmt.Errorf("'rule->selector' not set (rule %s[%d])", rule.Name, i+1) 30 | } 31 | if rule.Tags == "" { 32 | return fmt.Errorf("'rule->tags' not set (rule %s[%d])", rule.Name, i+1) 33 | } 34 | if len(rule.Match) == 0 { 35 | return fmt.Errorf("'rule->match' not set, need at least 1 rule match (rule %s[%d])", rule.Name, i+1) 36 | } 37 | 38 | for j, match := range rule.Match { 39 | if match.Tags == "" { 40 | return fmt.Errorf("'rule->match->tags' not set (rule %s[%d]/match [%d])", rule.Name, i+1, j+1) 41 | } 42 | if match.Expr == "" { 43 | return fmt.Errorf("'rule->match->expr' not set (rule %s[%d]/match [%d])", rule.Name, i+1, j+1) 44 | } 45 | } 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pipeline/sim_test.go: -------------------------------------------------------------------------------- 1 | package pipeline 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/netdata/sd/pipeline/model" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | type pipelineSim struct { 17 | discoveredGroups []model.Group 18 | expectedTag []model.Target 19 | expectedBuild []model.Target 20 | expectedExport []model.Config 21 | expectedCacheItems int 22 | } 23 | 24 | func (sim pipelineSim) run(t *testing.T) { 25 | require.NotEmpty(t, sim.discoveredGroups) 26 | 27 | discoverer := &mockDiscoverer{send: sim.discoveredGroups} 28 | tagger := &mockTagger{} 29 | builder := &mockBuilder{} 30 | exporter := &mockExporter{} 31 | 32 | p := New(discoverer, tagger, builder, exporter) 33 | 34 | var wg sync.WaitGroup 35 | ctx, cancel := context.WithCancel(context.Background()) 36 | 37 | wg.Add(1) 38 | go func() { defer wg.Done(); p.Run(ctx) }() 39 | 40 | time.Sleep(time.Second) 41 | cancel() 42 | wg.Wait() 43 | 44 | sortStaleConfigs(sim.expectedExport) 45 | sortStaleConfigs(exporter.seen) 46 | 47 | assert.Equal(t, sim.expectedTag, tagger.seen) 48 | assert.Equal(t, sim.expectedBuild, builder.seen) 49 | assert.Equal(t, sim.expectedExport, exporter.seen) 50 | if sim.expectedCacheItems >= 0 { 51 | assert.Equal(t, sim.expectedCacheItems, len(p.cache)) 52 | } 53 | } 54 | 55 | func sortStaleConfigs(cfgs []model.Config) { 56 | sort.Slice(cfgs, func(i, j int) bool { 57 | return cfgs[i].Stale && cfgs[j].Stale && cfgs[i].Conf < cfgs[j].Conf 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /pipeline/build/config.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type ( 9 | Config []RuleConfig // mandatory, at least 1 10 | RuleConfig struct { 11 | Name string `yaml:"name"` // optional 12 | Selector string `yaml:"selector"` // mandatory 13 | Tags string `yaml:"tags"` // mandatory 14 | Apply []ApplyConfig `yaml:"apply"` // mandatory, at least 1 15 | } 16 | ApplyConfig struct { 17 | Selector string `yaml:"selector"` // mandatory 18 | Tags string `yaml:"tags"` // optional 19 | Template string `yaml:"template"` // mandatory 20 | } 21 | ) 22 | 23 | func validateConfig(cfg Config) error { 24 | if len(cfg) == 0 { 25 | return errors.New("empty config, need least 1 rule") 26 | } 27 | for i, ruleCfg := range cfg { 28 | if ruleCfg.Selector == "" { 29 | return fmt.Errorf("'rule->selector' not set (rule %s[%d])", ruleCfg.Name, i+1) 30 | } 31 | 32 | if ruleCfg.Tags == "" { 33 | return fmt.Errorf("'rule->tags' not set (rule %s[%d])", ruleCfg.Name, i+1) 34 | } 35 | if len(ruleCfg.Apply) == 0 { 36 | return fmt.Errorf("'rule->apply' not set (rule %s[%d])", ruleCfg.Name, i+1) 37 | } 38 | 39 | for j, applyCfg := range ruleCfg.Apply { 40 | if applyCfg.Selector == "" { 41 | return fmt.Errorf("'rule->apply->selector' not set (rule %s[%d]/apply [%d])", 42 | ruleCfg.Name, i+1, j+1) 43 | } 44 | if applyCfg.Template == "" { 45 | return fmt.Errorf("'rule->apply->template' not set (rule %s[%d]/apply [%d])", 46 | ruleCfg.Name, i+1, j+1) 47 | } 48 | } 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/k8s/clientset.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "k8s.io/client-go/kubernetes" 9 | "k8s.io/client-go/kubernetes/fake" 10 | "k8s.io/client-go/rest" 11 | "k8s.io/client-go/tools/clientcmd" 12 | 13 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 14 | ) 15 | 16 | const ( 17 | EnvFakeClient = "KUBERNETES_FAKE_CLIENTSET" 18 | ) 19 | 20 | func Clientset() (kubernetes.Interface, error) { 21 | switch { 22 | case os.Getenv(EnvFakeClient) != "": 23 | return fake.NewSimpleClientset(), nil 24 | case InCluster(): 25 | return clientsetInCluster() 26 | default: 27 | return clientsetOutOfCluster() 28 | } 29 | } 30 | 31 | func clientsetInCluster() (*kubernetes.Clientset, error) { 32 | config, err := rest.InClusterConfig() 33 | if err != nil { 34 | return nil, err 35 | } 36 | config.UserAgent = "Netdata/auto-discovery" 37 | return kubernetes.NewForConfig(config) 38 | } 39 | 40 | func clientsetOutOfCluster() (*kubernetes.Clientset, error) { 41 | home := homeDir() 42 | if home == "" { 43 | return nil, errors.New("couldn't find home directory") 44 | } 45 | configPath := filepath.Join(home, ".kube", "config") 46 | config, err := clientcmd.BuildConfigFromFlags("", configPath) 47 | if err != nil { 48 | return nil, err 49 | } 50 | config.UserAgent = "Netdata/auto-discovery" 51 | return kubernetes.NewForConfig(config) 52 | } 53 | 54 | func homeDir() string { 55 | if h := os.Getenv("HOME"); h != "" { 56 | return h 57 | } 58 | return os.Getenv("USERPROFILE") // windows 59 | } 60 | 61 | func InCluster() bool { 62 | return os.Getenv("KUBERNETES_SERVICE_HOST") != "" && os.Getenv("KUBERNETES_SERVICE_PORT") != "" 63 | } 64 | -------------------------------------------------------------------------------- /pipeline/model/tags.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | type Tags map[string]struct{} 10 | 11 | func NewTags() Tags { 12 | return Tags{} 13 | } 14 | 15 | func (t Tags) Merge(tags Tags) { 16 | for tag := range tags { 17 | if strings.HasPrefix(tag, "-") { 18 | delete(t, tag[1:]) 19 | } else { 20 | t[tag] = struct{}{} 21 | } 22 | } 23 | } 24 | 25 | func (t Tags) String() string { 26 | ts := make([]string, 0, len(t)) 27 | for key := range t { 28 | ts = append(ts, key) 29 | } 30 | sort.Strings(ts) 31 | return fmt.Sprintf("{%s}", strings.Join(ts, ", ")) 32 | } 33 | 34 | func ParseTags(line string) (Tags, error) { 35 | words := strings.Fields(line) 36 | if len(words) == 0 { 37 | return NewTags(), nil 38 | } 39 | 40 | tags := NewTags() 41 | for _, tag := range words { 42 | if !isTagWordValid(tag) { 43 | return nil, fmt.Errorf("tags '%s' contains tag '%s' with forbidden symbol", line, tag) 44 | } 45 | tags[tag] = struct{}{} 46 | } 47 | return tags, nil 48 | } 49 | 50 | func MustParseTags(line string) Tags { 51 | tags, err := ParseTags(line) 52 | if err != nil { 53 | panic(fmt.Sprintf("tags '%s' parse error: %v", line, err)) 54 | } 55 | return tags 56 | } 57 | 58 | func isTagWordValid(word string) bool { 59 | // valid: 60 | // ^[a-zA-Z][a-zA-Z0-9=_.]*$ 61 | word = strings.TrimPrefix(word, "-") 62 | if len(word) == 0 { 63 | return false 64 | } 65 | for i, b := range word { 66 | switch { 67 | default: 68 | return false 69 | case b >= 'a' && b <= 'z': 70 | case b >= 'A' && b <= 'Z': 71 | case b >= '0' && b <= '9' && i > 0: 72 | case (b == '=' || b == '_' || b == '.') && i > 0: 73 | } 74 | } 75 | return true 76 | } 77 | -------------------------------------------------------------------------------- /pipeline/discovery/sim_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "testing" 7 | "time" 8 | 9 | "github.com/netdata/sd/pipeline/model" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type discoverySim struct { 16 | mgr *Manager 17 | collectDelay time.Duration 18 | expectedGroups []model.Group 19 | } 20 | 21 | func (sim discoverySim) run(t *testing.T) { 22 | t.Helper() 23 | require.NotNil(t, sim.mgr) 24 | 25 | in, out := make(chan []model.Group), make(chan []model.Group) 26 | go sim.collectGroups(t, in, out) 27 | 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | defer cancel() 30 | go sim.mgr.Discover(ctx, in) 31 | 32 | actualGroups := <-out 33 | 34 | sortGroups(sim.expectedGroups) 35 | sortGroups(actualGroups) 36 | 37 | assert.Equal(t, sim.expectedGroups, actualGroups) 38 | } 39 | 40 | func (sim discoverySim) collectGroups(t *testing.T, in, out chan []model.Group) { 41 | time.Sleep(sim.collectDelay) 42 | 43 | timeout := sim.mgr.sendEvery + time.Second*2 44 | var groups []model.Group 45 | loop: 46 | for { 47 | select { 48 | case inGroups := <-in: 49 | if groups = append(groups, inGroups...); len(groups) >= len(sim.expectedGroups) { 50 | break loop 51 | } 52 | case <-time.After(timeout): 53 | t.Logf("discovery %s timed out after %s, got %d groups, expected %d, some events are skipped", 54 | sim.mgr.discoverers, timeout, len(groups), len(sim.expectedGroups)) 55 | break loop 56 | } 57 | } 58 | out <- groups 59 | } 60 | 61 | func sortGroups(groups []model.Group) { 62 | if len(groups) == 0 { 63 | return 64 | } 65 | sort.Slice(groups, func(i, j int) bool { return groups[i].Source() < groups[j].Source() }) 66 | } 67 | -------------------------------------------------------------------------------- /manager/config/provider/kubernetes/sim_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/netdata/sd/manager/config" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "k8s.io/client-go/tools/cache" 13 | ) 14 | 15 | const ( 16 | startDeadline = time.Second 17 | collectDeadline = time.Second * 2 18 | ) 19 | 20 | type runSim struct { 21 | provider *Provider 22 | runAfterSync func(ctx context.Context) 23 | expectedConfigs []config.Config 24 | } 25 | 26 | func (sim runSim) run(t *testing.T) { 27 | t.Helper() 28 | require.NotNil(t, sim.provider) 29 | 30 | collect := make(chan []config.Config) 31 | go sim.collect(t, collect) 32 | 33 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 34 | defer cancel() 35 | go sim.provider.Run(ctx) 36 | 37 | select { 38 | case <-sim.provider.started: 39 | case <-time.After(startDeadline): 40 | t.Fatalf("provider '%s' filed to start in %s", *sim.provider, startDeadline) 41 | } 42 | 43 | synced := cache.WaitForCacheSync(ctx.Done(), sim.provider.inf.HasSynced) 44 | require.Truef(t, synced, "provider '%s' failed to sync", *sim.provider) 45 | 46 | if sim.runAfterSync != nil { 47 | sim.runAfterSync(ctx) 48 | } 49 | 50 | assert.Equal(t, sim.expectedConfigs, <-collect) 51 | } 52 | 53 | func (sim runSim) collect(t *testing.T, in chan []config.Config) { 54 | var configs []config.Config 55 | loop: 56 | for { 57 | select { 58 | case updates := <-sim.provider.Configs(): 59 | if configs = append(configs, updates...); len(configs) >= len(sim.expectedConfigs) { 60 | break loop 61 | } 62 | case <-time.After(collectDeadline): 63 | t.Logf("provider '%s' timed out after %s, got %d configs, expected %d, some events are skipped", 64 | *sim.provider, collectDeadline, len(configs), len(sim.expectedConfigs)) 65 | break loop 66 | } 67 | } 68 | in <- configs 69 | } 70 | -------------------------------------------------------------------------------- /manager/sim_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/netdata/sd/manager/config" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var lock = &sync.Mutex{} 15 | 16 | type runSim struct { 17 | configs []config.Config 18 | expectedBeforeStop []*mockPipeline 19 | expectedAfterStop []*mockPipeline 20 | } 21 | 22 | func (sim runSim) run(t *testing.T) { 23 | provider := &mockProvider{ 24 | cfgs: sim.configs, 25 | ch: make(chan []config.Config), 26 | } 27 | fact := &mockFactory{} 28 | mgr := New(provider) 29 | mgr.factory = fact 30 | 31 | var wg sync.WaitGroup 32 | ctx, cancel := context.WithCancel(context.Background()) 33 | 34 | wg.Add(1) 35 | go func() { defer wg.Done(); mgr.Run(ctx) }() 36 | time.Sleep(time.Second) 37 | 38 | lock.Lock() 39 | assert.Equalf(t, sim.expectedBeforeStop, fact.created, "before stop") 40 | lock.Unlock() 41 | 42 | cancel() 43 | wg.Wait() 44 | 45 | lock.Lock() 46 | assert.Equalf(t, sim.expectedAfterStop, fact.created, "after stop") 47 | lock.Unlock() 48 | } 49 | 50 | type ( 51 | mockProvider struct { 52 | cfgs []config.Config 53 | ch chan []config.Config 54 | } 55 | mockPipeline struct { 56 | name string 57 | started bool 58 | stopped bool 59 | } 60 | mockFactory struct { 61 | created []*mockPipeline 62 | } 63 | ) 64 | 65 | func (m mockProvider) Run(ctx context.Context) { 66 | select { 67 | case <-ctx.Done(): 68 | case m.ch <- m.cfgs: 69 | } 70 | <-ctx.Done() 71 | } 72 | 73 | func (m mockProvider) Configs() chan []config.Config { 74 | return m.ch 75 | } 76 | 77 | func (m *mockPipeline) Run(ctx context.Context) { 78 | lock.Lock() 79 | m.started = true 80 | lock.Unlock() 81 | defer func() { lock.Lock(); defer lock.Unlock(); m.stopped = true }() 82 | <-ctx.Done() 83 | } 84 | 85 | func (m *mockFactory) create(cfg config.PipelineConfig) (sdPipeline, error) { 86 | lock.Lock() 87 | defer lock.Unlock() 88 | 89 | if cfg.Name == "invalid" { 90 | return nil, errors.New("mock factory error") 91 | } 92 | p := &mockPipeline{name: cfg.Name} 93 | m.created = append(m.created, p) 94 | return p, nil 95 | } 96 | -------------------------------------------------------------------------------- /pkg/funcmap/funcmap_test.go: -------------------------------------------------------------------------------- 1 | package funcmap 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_globAny(t *testing.T) { 11 | tests := map[string]struct { 12 | patterns []string 13 | value string 14 | wantFalse bool 15 | }{ 16 | "one param, matches": { 17 | patterns: []string{"*"}, 18 | value: "value", 19 | }, 20 | "one param, matches with *": { 21 | patterns: []string{"**/value"}, 22 | value: "/one/two/three/value", 23 | }, 24 | "one param, not matches": { 25 | patterns: []string{"Value"}, 26 | value: "value", 27 | wantFalse: true, 28 | }, 29 | "several params, last one matches": { 30 | patterns: []string{"not", "matches", "*"}, 31 | value: "value", 32 | }, 33 | "several params, no matches": { 34 | patterns: []string{"not", "matches", "really"}, 35 | value: "value", 36 | wantFalse: true, 37 | }, 38 | } 39 | 40 | for name, test := range tests { 41 | name := fmt.Sprintf("name: %s, patterns: '%v', value: '%s'", name, test.patterns, test.value) 42 | 43 | if test.wantFalse { 44 | assert.Falsef(t, globAny(test.value, test.patterns[0], test.patterns[1:]...), name) 45 | } else { 46 | assert.Truef(t, globAny(test.value, test.patterns[0], test.patterns[1:]...), name) 47 | } 48 | } 49 | } 50 | 51 | func Test_regexpAny(t *testing.T) { 52 | tests := map[string]struct { 53 | patterns []string 54 | value string 55 | wantFalse bool 56 | }{ 57 | "one param, matches": { 58 | patterns: []string{"^value$"}, 59 | value: "value", 60 | }, 61 | "one param, not matches": { 62 | patterns: []string{"^Value$"}, 63 | value: "value", 64 | wantFalse: true, 65 | }, 66 | "several params, last one matches": { 67 | patterns: []string{"not", "matches", "va[lue]{3}"}, 68 | value: "value", 69 | }, 70 | "several params, no matches": { 71 | patterns: []string{"not", "matches", "val[^l]ue"}, 72 | value: "value", 73 | wantFalse: true, 74 | }, 75 | } 76 | 77 | for name, test := range tests { 78 | name := fmt.Sprintf("name: %s, patterns: '%v', value: '%s'", name, test.patterns, test.value) 79 | 80 | if test.wantFalse { 81 | assert.Falsef(t, regexpAny(test.value, test.patterns[0], test.patterns[1:]...), name) 82 | } else { 83 | assert.Truef(t, regexpAny(test.value, test.patterns[0], test.patterns[1:]...), name) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/funcmap/funcmap.go: -------------------------------------------------------------------------------- 1 | package funcmap 2 | 3 | import ( 4 | "regexp" 5 | "sync" 6 | "text/template" 7 | 8 | "github.com/Masterminds/sprig/v3" 9 | "github.com/gobwas/glob" 10 | ) 11 | 12 | var FuncMap = func() template.FuncMap { 13 | fm := sprig.TxtFuncMap() 14 | 15 | for name, fn := range extraFuncMap { 16 | fm[name] = fn 17 | } 18 | 19 | return fm 20 | }() 21 | 22 | var extraFuncMap = map[string]interface{}{ 23 | "glob": globAny, 24 | "re": regexpAny, 25 | } 26 | 27 | func globAny(value, pattern string, rest ...string) bool { 28 | switch len(rest) { 29 | case 0: 30 | return globOnce(value, pattern) 31 | default: 32 | return globOnce(value, pattern) || globAny(value, rest[0], rest[1:]...) 33 | } 34 | } 35 | 36 | func regexpAny(value, pattern string, rest ...string) bool { 37 | switch len(rest) { 38 | case 0: 39 | return regexpOnce(value, pattern) 40 | default: 41 | return regexpOnce(value, pattern) || regexpAny(value, rest[0], rest[1:]...) 42 | } 43 | } 44 | 45 | func globOnce(value, pattern string) bool { 46 | g, _ := globStore(pattern) 47 | return g != nil && g.Match(value) 48 | } 49 | 50 | func regexpOnce(value, pattern string) bool { 51 | r, _ := regexpStore(pattern) 52 | return r != nil && r.MatchString(value) 53 | } 54 | 55 | // TODO: cleanup? 56 | var globStore = func() func(pattern string) (glob.Glob, error) { 57 | var l sync.RWMutex 58 | store := make(map[string]struct { 59 | g glob.Glob 60 | err error 61 | }) 62 | 63 | return func(pattern string) (glob.Glob, error) { 64 | if pattern == "" { 65 | return nil, nil 66 | } 67 | l.Lock() 68 | defer l.Unlock() 69 | entry, ok := store[pattern] 70 | if !ok { 71 | entry.g, entry.err = glob.Compile(pattern, '/') 72 | store[pattern] = entry 73 | } 74 | return entry.g, entry.err 75 | } 76 | }() 77 | 78 | // TODO: cleanup? 79 | var regexpStore = func() func(pattern string) (*regexp.Regexp, error) { 80 | var l sync.RWMutex 81 | store := make(map[string]struct { 82 | r *regexp.Regexp 83 | err error 84 | }) 85 | 86 | return func(pattern string) (*regexp.Regexp, error) { 87 | if pattern == "" { 88 | return nil, nil 89 | } 90 | l.Lock() 91 | defer l.Unlock() 92 | entry, ok := store[pattern] 93 | if !ok { 94 | entry.r, entry.err = regexp.Compile(pattern) 95 | store[pattern] = entry 96 | } 97 | return entry.r, entry.err 98 | } 99 | }() 100 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/netdata/sd 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.3.0 7 | github.com/fsnotify/fsnotify v1.8.0 8 | github.com/gobwas/glob v0.2.3 9 | github.com/ilyam8/hashstructure v1.1.0 10 | github.com/jessevdk/go-flags v1.6.1 11 | github.com/mattn/go-isatty v0.0.20 12 | github.com/rs/zerolog v1.33.0 13 | github.com/stretchr/testify v1.10.0 14 | gopkg.in/yaml.v2 v2.4.0 15 | k8s.io/api v0.32.1 16 | k8s.io/apimachinery v0.32.1 17 | k8s.io/client-go v0.32.1 18 | ) 19 | 20 | require ( 21 | dario.cat/mergo v1.0.1 // indirect 22 | github.com/Masterminds/goutils v1.1.1 // indirect 23 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 26 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 27 | github.com/go-logr/logr v1.4.2 // indirect 28 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 29 | github.com/go-openapi/jsonreference v0.20.2 // indirect 30 | github.com/go-openapi/swag v0.23.0 // indirect 31 | github.com/gogo/protobuf v1.3.2 // indirect 32 | github.com/golang/protobuf v1.5.4 // indirect 33 | github.com/google/gnostic-models v0.6.8 // indirect 34 | github.com/google/go-cmp v0.6.0 // indirect 35 | github.com/google/gofuzz v1.2.0 // indirect 36 | github.com/google/uuid v1.6.0 // indirect 37 | github.com/huandu/xstrings v1.5.0 // indirect 38 | github.com/josharian/intern v1.0.0 // indirect 39 | github.com/json-iterator/go v1.1.12 // indirect 40 | github.com/mailru/easyjson v0.7.7 // indirect 41 | github.com/mattn/go-colorable v0.1.13 // indirect 42 | github.com/mitchellh/copystructure v1.2.0 // indirect 43 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 45 | github.com/modern-go/reflect2 v1.0.2 // indirect 46 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 47 | github.com/pkg/errors v0.9.1 // indirect 48 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 49 | github.com/shopspring/decimal v1.4.0 // indirect 50 | github.com/spf13/cast v1.7.0 // indirect 51 | github.com/spf13/pflag v1.0.5 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | golang.org/x/crypto v0.31.0 // indirect 54 | golang.org/x/net v0.33.0 // indirect 55 | golang.org/x/oauth2 v0.23.0 // indirect 56 | golang.org/x/sys v0.28.0 // indirect 57 | golang.org/x/term v0.27.0 // indirect 58 | golang.org/x/text v0.21.0 // indirect 59 | golang.org/x/time v0.7.0 // indirect 60 | google.golang.org/protobuf v1.35.1 // indirect 61 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 62 | gopkg.in/inf.v0 v0.9.1 // indirect 63 | gopkg.in/yaml.v3 v3.0.1 // indirect 64 | k8s.io/klog/v2 v2.130.1 // indirect 65 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 66 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 67 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 68 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 69 | sigs.k8s.io/yaml v1.4.0 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /cmd/sd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/netdata/sd/manager" 14 | "github.com/netdata/sd/manager/config/provider/file" 15 | "github.com/netdata/sd/manager/config/provider/kubernetes" 16 | "github.com/netdata/sd/pkg/log" 17 | 18 | "github.com/jessevdk/go-flags" 19 | "github.com/rs/zerolog" 20 | ) 21 | 22 | type options struct { 23 | ConfigFile string `long:"config-file" env:"NETDATA_SD_CONFIG_FILE" description:"Configuration file path"` 24 | ConfigMap string `long:"config-map" env:"NETDATA_SD_CONFIG_MAP" description:"Configuration ConfigMap (name:key)"` 25 | Debug bool `short:"d" long:"debug" description:"Debug mode"` 26 | } 27 | 28 | var logger = log.New("main") 29 | 30 | func main() { 31 | opts := parseCLI() 32 | 33 | if err := validateOptions(opts); err != nil { 34 | logger.Fatal().Err(err).Msg("failed to validate cli options") 35 | } 36 | 37 | if opts.Debug { 38 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 39 | } else { 40 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 41 | } 42 | 43 | provider, err := newConfigProvider(opts) 44 | if err != nil { 45 | logger.Fatal().Err(err).Msg("failed to create config provider") 46 | } 47 | 48 | mgr := manager.New(provider) 49 | run(mgr) 50 | } 51 | 52 | func run(mgr *manager.Manager) { 53 | var wg sync.WaitGroup 54 | ctx, cancel := context.WithCancel(context.Background()) 55 | 56 | wg.Add(1) 57 | go func() { defer wg.Done(); mgr.Run(ctx) }() 58 | 59 | ch := make(chan os.Signal, 1) 60 | signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 61 | 62 | sig := <-ch 63 | logger.Info().Msgf("received %s signal (%d). Terminating...", sig, sig) 64 | cancel() 65 | wg.Wait() 66 | } 67 | 68 | func parseCLI() options { 69 | var opts options 70 | parser := flags.NewParser(&opts, flags.Default) 71 | parser.Name = "sd" 72 | parser.Usage = "[OPTION]..." 73 | 74 | if _, err := parser.ParseArgs(os.Args); err != nil { 75 | if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { 76 | os.Exit(0) 77 | } else { 78 | os.Exit(1) 79 | } 80 | } 81 | return opts 82 | } 83 | 84 | func validateOptions(opts options) error { 85 | if opts.ConfigFile == "" && opts.ConfigMap == "" { 86 | return errors.New("configuration source not set") 87 | } 88 | return nil 89 | } 90 | 91 | func newConfigProvider(opts options) (manager.ConfigProvider, error) { 92 | if opts.ConfigFile != "" { 93 | return file.NewProvider([]string{opts.ConfigFile}), nil 94 | } 95 | 96 | parts := strings.Split(strings.TrimSpace(opts.ConfigMap), ":") 97 | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 98 | return nil, fmt.Errorf("config-map parameter bad syntax ('%s')", opts.ConfigMap) 99 | } 100 | provider, err := kubernetes.NewProvider(kubernetes.Config{ 101 | Namespace: os.Getenv("MY_POD_NAMESPACE"), 102 | ConfigMap: parts[0], 103 | Key: parts[1], 104 | }) 105 | if err != nil { 106 | return nil, err 107 | } 108 | return provider, nil 109 | } 110 | -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | branches: 6 | - master 7 | pull_request: null 8 | name: Test and Deploy 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | go-version: [1.23.x] 15 | platform: [ubuntu-latest] 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - name: Install Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Go mod download 25 | run: go mod download 26 | - name: Compile 27 | run: | 28 | CGO_ENABLED=0 go build -o /tmp/sd github.com/netdata/sd/cmd/sd 29 | /tmp/sd --help || true 30 | - name: Enforce formatted code 31 | run: "! go fmt ./... 2>&1 | read" 32 | - name: Go vet 33 | run: go vet ./... 34 | - name: Go test 35 | run: go test ./... -race -count=1 36 | - name: Golangci-lint 37 | uses: reviewdog/action-golangci-lint@v2 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | reporter: github-pr-check 41 | deploy: 42 | needs: [test] 43 | name: Build and deploy Docker images 44 | runs-on: ubuntu-latest 45 | if: > 46 | github.event_name == 'push' && 47 | (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) && 48 | github.repository == 'netdata/agent-service-discovery' 49 | env: 50 | DOCKER_CLI_EXPERIMENTAL: enabled # for 'docker buildx' 51 | DOCKER_USER: ${{secrets.DOCKER_USERNAME}} 52 | DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}} 53 | DOCKER_REPO: netdata/agent-sd 54 | DOCKER_PLATFORMS: > 55 | linux/amd64 56 | linux/arm/v7 57 | linux/arm64 58 | steps: 59 | - name: Install Go 60 | uses: actions/setup-go@v5 61 | with: 62 | go-version: '1.23.x' 63 | - name: Checkout 64 | uses: actions/checkout@v4 65 | - name: Set up image tag 66 | run: | 67 | set -vx 68 | # Export environment variable for later stages. 69 | if echo "$GITHUB_REF" | grep -q '^refs/heads/'; then 70 | # Pushes to (master) branch - deploy 'latest'. 71 | echo "TAG=latest" >> $GITHUB_ENV 72 | elif echo "$GITHUB_REF" | grep -q '^refs/tags/'; then 73 | # Pushes tag - deploy tag name. 74 | echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 75 | fi 76 | echo "DOCKER_BASE=${DOCKER_REPO}" >> $GITHUB_ENV 77 | - name: Set up QEMU 78 | uses: docker/setup-qemu-action@v3 79 | - name: Set up Docker Buildx 80 | uses: docker/setup-buildx-action@v3 81 | - name: Docker login 82 | run: echo "$DOCKER_PASSWORD" | docker login -u="$DOCKER_USER" --password-stdin 83 | - name: Build multi-architecture Docker images with buildx 84 | run: | 85 | set -vx 86 | function buildx() { 87 | docker buildx build \ 88 | --platform ${DOCKER_PLATFORMS// /,} \ 89 | --push \ 90 | "$@" \ 91 | . 92 | } 93 | buildx -t "$DOCKER_BASE:$TAG" 94 | - name: Docker logout 95 | run: docker logout 96 | -------------------------------------------------------------------------------- /pipeline/export/manager.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "sync" 9 | 10 | "github.com/netdata/sd/pipeline/model" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/mattn/go-isatty" 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) 18 | 19 | type ( 20 | Config struct { 21 | File []FileConfig `yaml:"file"` 22 | } 23 | FileConfig struct { 24 | Selector string `yaml:"selector"` 25 | Filename string `yaml:"filename"` 26 | } 27 | ) 28 | 29 | func validateConfig(conf Config) error { 30 | if len(conf.File) == 0 && !isTerminal { 31 | return errors.New("empty config") 32 | } 33 | 34 | seen := make(map[string]bool) 35 | for i, cfg := range conf.File { 36 | if cfg.Selector == "" { 37 | return fmt.Errorf("'file->selector' not set [%d]", i+1) 38 | } 39 | if cfg.Filename == "" { 40 | return fmt.Errorf("'file->filename' not set [%d]", i+1) 41 | } 42 | if seen[cfg.Filename] { 43 | return fmt.Errorf("duplicate filename: '%s'", cfg.Filename) 44 | } 45 | seen[cfg.Filename] = true 46 | } 47 | return nil 48 | } 49 | 50 | type ( 51 | Manager struct { 52 | exporters []exporter 53 | log zerolog.Logger 54 | } 55 | exporter interface { 56 | Export(ctx context.Context, out <-chan []model.Config) 57 | } 58 | ) 59 | 60 | func New(cfg Config) (*Manager, error) { 61 | if err := validateConfig(cfg); err != nil { 62 | return nil, err 63 | } 64 | mgr := &Manager{ 65 | log: log.New("export manager"), 66 | } 67 | if err := mgr.registerExporters(cfg); err != nil { 68 | return nil, err 69 | } 70 | 71 | mgr.log.Info().Msgf("registered: '%v'", mgr.exporters) 72 | return mgr, nil 73 | } 74 | 75 | func (m *Manager) registerExporters(conf Config) error { 76 | for _, cfg := range conf.File { 77 | sr, err := model.ParseSelector(cfg.Selector) 78 | if err != nil { 79 | return err 80 | } 81 | m.exporters = append(m.exporters, NewFile(sr, cfg.Filename)) 82 | } 83 | if isTerminal { 84 | m.exporters = append(m.exporters, newStdout()) 85 | } 86 | return nil 87 | } 88 | 89 | func (m *Manager) Export(ctx context.Context, out <-chan []model.Config) { 90 | m.log.Info().Msg("instance is started") 91 | defer m.log.Info().Msg("instance is stopped") 92 | 93 | var wg sync.WaitGroup 94 | outs := make([]chan<- []model.Config, 0, len(m.exporters)) 95 | 96 | for _, e := range m.exporters { 97 | eOut := make(chan []model.Config) 98 | outs = append(outs, eOut) 99 | 100 | wg.Add(1) 101 | go func(e exporter) { defer wg.Done(); e.Export(ctx, eOut) }(e) 102 | } 103 | 104 | wg.Add(1) 105 | go func() { defer wg.Done(); m.run(ctx, out, outs) }() 106 | 107 | wg.Wait() 108 | <-ctx.Done() 109 | } 110 | 111 | func (m Manager) run(ctx context.Context, out <-chan []model.Config, outs []chan<- []model.Config) { 112 | for { 113 | select { 114 | case <-ctx.Done(): 115 | return 116 | case cfgs := <-out: 117 | m.notify(ctx, cfgs, outs) 118 | } 119 | } 120 | } 121 | 122 | func (m Manager) notify(ctx context.Context, cfgs []model.Config, outs []chan<- []model.Config) { 123 | for _, out := range outs { 124 | select { 125 | case <-ctx.Done(): 126 | return 127 | case out <- cfgs: 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pipeline/tag/tag.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/netdata/sd/pipeline/model" 11 | "github.com/netdata/sd/pkg/funcmap" 12 | "github.com/netdata/sd/pkg/log" 13 | 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | type ( 18 | Manager struct { 19 | rules []*tagRule 20 | buf bytes.Buffer 21 | log zerolog.Logger 22 | } 23 | tagRule struct { 24 | name string 25 | id int 26 | sr model.Selector 27 | tags model.Tags 28 | match []*ruleMatch 29 | } 30 | ruleMatch struct { 31 | id int 32 | sr model.Selector 33 | tags model.Tags 34 | expr *template.Template 35 | } 36 | ) 37 | 38 | func New(cfg Config) (*Manager, error) { 39 | if err := validateConfig(cfg); err != nil { 40 | return nil, fmt.Errorf("tag manager config validation: %v", err) 41 | } 42 | mgr, err := initManager(cfg) 43 | if err != nil { 44 | return nil, fmt.Errorf("tag manager initialization: %v", err) 45 | } 46 | return mgr, nil 47 | } 48 | 49 | func (m *Manager) Tag(target model.Target) { 50 | for _, rule := range m.rules { 51 | if !rule.sr.Matches(target.Tags()) { 52 | continue 53 | } 54 | 55 | for _, match := range rule.match { 56 | if !match.sr.Matches(target.Tags()) { 57 | continue 58 | } 59 | 60 | m.buf.Reset() 61 | if err := match.expr.Execute(&m.buf, target); err != nil { 62 | m.log.Warn().Err(err).Msgf("failed to execute rule match '%d/%d' on target '%s'", 63 | rule.id, match.id, target.TUID()) 64 | continue 65 | } 66 | if strings.TrimSpace(m.buf.String()) != "true" { 67 | continue 68 | } 69 | 70 | target.Tags().Merge(rule.tags) 71 | target.Tags().Merge(match.tags) 72 | m.log.Debug().Msgf("matched target '%s', tags: %s", target.TUID(), target.Tags()) 73 | } 74 | } 75 | } 76 | 77 | func initManager(conf Config) (*Manager, error) { 78 | if len(conf) == 0 { 79 | return nil, errors.New("empty config") 80 | } 81 | 82 | mgr := &Manager{ 83 | rules: nil, 84 | log: log.New("tag manager"), 85 | } 86 | for i, cfg := range conf { 87 | rule := tagRule{id: i + 1, name: cfg.Name} 88 | if sr, err := model.ParseSelector(cfg.Selector); err != nil { 89 | return nil, err 90 | } else { 91 | rule.sr = sr 92 | } 93 | 94 | if tags, err := model.ParseTags(cfg.Tags); err != nil { 95 | return nil, err 96 | } else { 97 | rule.tags = tags 98 | } 99 | 100 | for i, cfg := range cfg.Match { 101 | match := ruleMatch{id: i + 1} 102 | if sr, err := model.ParseSelector(cfg.Selector); err != nil { 103 | return nil, err 104 | } else { 105 | match.sr = sr 106 | } 107 | 108 | if tags, err := model.ParseTags(cfg.Tags); err != nil { 109 | return nil, err 110 | } else { 111 | match.tags = tags 112 | } 113 | 114 | if tmpl, err := parseTemplate(cfg.Expr); err != nil { 115 | return nil, err 116 | } else { 117 | match.expr = tmpl 118 | } 119 | 120 | rule.match = append(rule.match, &match) 121 | } 122 | mgr.rules = append(mgr.rules, &rule) 123 | } 124 | return mgr, nil 125 | } 126 | 127 | func parseTemplate(line string) (*template.Template, error) { 128 | return template.New("root"). 129 | Option("missingkey=error"). 130 | Funcs(funcmap.FuncMap). 131 | Parse(line) 132 | } 133 | -------------------------------------------------------------------------------- /pipeline/discovery/manager.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | "github.com/netdata/sd/pipeline/discovery/kubernetes" 10 | "github.com/netdata/sd/pipeline/model" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/rs/zerolog" 14 | ) 15 | 16 | type Config struct { 17 | K8S []kubernetes.Config `yaml:"k8s"` 18 | } 19 | 20 | func validateConfig(cfg Config) error { 21 | if len(cfg.K8S) == 0 { 22 | return errors.New("empty config") 23 | } 24 | return nil 25 | } 26 | 27 | type ( 28 | discoverer interface { 29 | Discover(ctx context.Context, in chan<- []model.Group) 30 | } 31 | Manager struct { 32 | discoverers []discoverer 33 | send chan struct{} 34 | sendEvery time.Duration 35 | cache *cache 36 | log zerolog.Logger 37 | } 38 | ) 39 | 40 | func New(cfg Config) (*Manager, error) { 41 | if err := validateConfig(cfg); err != nil { 42 | return nil, err 43 | } 44 | mgr := &Manager{ 45 | send: make(chan struct{}, 1), 46 | sendEvery: 5 * time.Second, 47 | discoverers: make([]discoverer, 0), 48 | cache: newCache(), 49 | log: log.New("discovery manager"), 50 | } 51 | if err := mgr.registerDiscoverers(cfg); err != nil { 52 | return nil, err 53 | } 54 | 55 | mgr.log.Info().Msgf("registered: %v", mgr.discoverers) 56 | return mgr, nil 57 | } 58 | 59 | func (m *Manager) registerDiscoverers(conf Config) error { 60 | for _, cfg := range conf.K8S { 61 | d, err := kubernetes.NewDiscovery(cfg) 62 | if err != nil { 63 | return err 64 | } 65 | m.discoverers = append(m.discoverers, d) 66 | } 67 | return nil 68 | } 69 | 70 | func (m *Manager) Discover(ctx context.Context, in chan<- []model.Group) { 71 | m.log.Info().Msg("instance is started") 72 | defer m.log.Info().Msg("instance is stopped") 73 | 74 | var wg sync.WaitGroup 75 | 76 | for _, d := range m.discoverers { 77 | wg.Add(1) 78 | go func(d discoverer) { defer wg.Done(); m.runDiscoverer(ctx, d) }(d) 79 | } 80 | 81 | wg.Add(1) 82 | go func() { defer wg.Done(); m.run(ctx, in) }() 83 | 84 | wg.Wait() 85 | <-ctx.Done() 86 | } 87 | 88 | func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) { 89 | updates := make(chan []model.Group) 90 | go d.Discover(ctx, updates) 91 | 92 | for { 93 | select { 94 | case <-ctx.Done(): 95 | return 96 | case groups, ok := <-updates: 97 | if !ok { 98 | return 99 | } 100 | func() { 101 | m.cache.mu.Lock() 102 | defer m.cache.mu.Unlock() 103 | 104 | m.cache.update(groups) 105 | m.triggerSend() 106 | }() 107 | } 108 | } 109 | } 110 | 111 | func (m *Manager) run(ctx context.Context, in chan<- []model.Group) { 112 | tk := time.NewTicker(m.sendEvery) 113 | defer tk.Stop() 114 | 115 | for { 116 | select { 117 | case <-ctx.Done(): 118 | return 119 | case <-tk.C: 120 | select { 121 | case <-m.send: 122 | m.trySend(in) 123 | default: 124 | } 125 | } 126 | } 127 | } 128 | 129 | func (m *Manager) trySend(in chan<- []model.Group) { 130 | m.cache.mu.Lock() 131 | defer m.cache.mu.Unlock() 132 | 133 | select { 134 | case in <- m.cache.asList(): 135 | m.cache.reset() 136 | default: 137 | m.triggerSend() 138 | } 139 | } 140 | 141 | func (m *Manager) triggerSend() { 142 | select { 143 | case m.send <- struct{}{}: 144 | default: 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /pipeline/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "text/template" 8 | 9 | "github.com/netdata/sd/pipeline/model" 10 | "github.com/netdata/sd/pkg/funcmap" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/rs/zerolog" 14 | ) 15 | 16 | type ( 17 | Manager struct { 18 | rules []*buildRule 19 | buf bytes.Buffer 20 | log zerolog.Logger 21 | } 22 | buildRule struct { 23 | name string 24 | id int 25 | sr model.Selector 26 | tags model.Tags 27 | apply []*ruleApply 28 | } 29 | ruleApply struct { 30 | id int 31 | sr model.Selector 32 | tags model.Tags 33 | tmpl *template.Template 34 | } 35 | ) 36 | 37 | func New(cfg Config) (*Manager, error) { 38 | if err := validateConfig(cfg); err != nil { 39 | return nil, fmt.Errorf("build manager config validation: %v", err) 40 | } 41 | mgr, err := initManager(cfg) 42 | if err != nil { 43 | return nil, fmt.Errorf("build manager initialization: %v", err) 44 | } 45 | return mgr, nil 46 | } 47 | 48 | func (m *Manager) Build(target model.Target) (configs []model.Config) { 49 | for _, rule := range m.rules { 50 | if !rule.sr.Matches(target.Tags()) { 51 | continue 52 | } 53 | 54 | for _, apply := range rule.apply { 55 | if !apply.sr.Matches(target.Tags()) { 56 | continue 57 | } 58 | 59 | m.buf.Reset() 60 | if err := apply.tmpl.Execute(&m.buf, target); err != nil { 61 | m.log.Warn().Err(err).Msgf("failed to execute rule apply '%d/%d' on target '%s'", 62 | rule.id, apply.id, target.TUID()) 63 | continue 64 | } 65 | 66 | cfg := model.Config{ 67 | Tags: model.NewTags(), 68 | Conf: m.buf.String(), 69 | } 70 | 71 | cfg.Tags.Merge(rule.tags) 72 | cfg.Tags.Merge(apply.tags) 73 | configs = append(configs, cfg) 74 | } 75 | } 76 | if len(configs) > 0 { 77 | m.log.Info().Msgf("built %d config(s) for target '%s'", len(configs), target.TUID()) 78 | } 79 | return configs 80 | } 81 | 82 | func initManager(conf Config) (*Manager, error) { 83 | if len(conf) == 0 { 84 | return nil, errors.New("empty config") 85 | } 86 | mgr := &Manager{ 87 | log: log.New("build manager"), 88 | } 89 | 90 | for i, cfg := range conf { 91 | rule := buildRule{id: i + 1, name: cfg.Name} 92 | if sr, err := model.ParseSelector(cfg.Selector); err != nil { 93 | return nil, err 94 | } else { 95 | rule.sr = sr 96 | } 97 | 98 | if tags, err := model.ParseTags(cfg.Tags); err != nil { 99 | return nil, err 100 | } else { 101 | rule.tags = tags 102 | } 103 | 104 | for i, cfg := range cfg.Apply { 105 | apply := ruleApply{id: i + 1} 106 | if sr, err := model.ParseSelector(cfg.Selector); err != nil { 107 | return nil, err 108 | } else { 109 | apply.sr = sr 110 | } 111 | 112 | if tags, err := model.ParseTags(cfg.Tags); err != nil { 113 | return nil, err 114 | } else { 115 | apply.tags = tags 116 | } 117 | 118 | if tmpl, err := parseTemplate(cfg.Template); err != nil { 119 | return nil, err 120 | } else { 121 | apply.tmpl = tmpl 122 | } 123 | 124 | rule.apply = append(rule.apply, &apply) 125 | } 126 | mgr.rules = append(mgr.rules, &rule) 127 | } 128 | return mgr, nil 129 | } 130 | 131 | func parseTemplate(line string) (*template.Template, error) { 132 | return template.New("root"). 133 | Option("missingkey=error"). 134 | Funcs(funcmap.FuncMap). 135 | Parse(line) 136 | } 137 | -------------------------------------------------------------------------------- /pipeline/export/export.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/netdata/sd/pipeline/model" 12 | "github.com/netdata/sd/pkg/log" 13 | 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | type File struct { 18 | sr model.Selector 19 | file string 20 | cache cache 21 | dump bool 22 | wr *bufio.Writer 23 | log zerolog.Logger 24 | } 25 | 26 | func NewFile(sr model.Selector, file string) *File { 27 | return &File{ 28 | sr: sr, 29 | file: file, 30 | cache: make(cache), 31 | log: log.New("file export"), 32 | } 33 | } 34 | 35 | func (f File) String() string { 36 | return fmt.Sprintf("file exporter (%s)", f.file) 37 | } 38 | 39 | func (f *File) Export(ctx context.Context, out <-chan []model.Config) { 40 | f.log.Info().Msg("instance is started") 41 | defer f.log.Info().Msg("instance is stopped") 42 | 43 | const exportEvery = time.Second * 1 44 | tk := time.NewTicker(exportEvery) 45 | defer tk.Stop() 46 | 47 | for { 48 | select { 49 | case <-ctx.Done(): 50 | return 51 | case cfgs := <-out: 52 | f.process(cfgs) 53 | case <-tk.C: 54 | f.export() 55 | } 56 | } 57 | } 58 | 59 | func (f *File) process(cfgs []model.Config) { 60 | for _, cfg := range cfgs { 61 | if !f.sr.Matches(cfg.Tags) { 62 | continue 63 | } 64 | if changed := f.cache.put(cfg); changed && !f.dump { 65 | f.dump = true 66 | } 67 | } 68 | } 69 | 70 | func (f *File) export() { 71 | if !f.dump { 72 | return 73 | } 74 | fi, err := os.Create(f.file) 75 | if err != nil { 76 | f.log.Warn().Err(err).Msg("failed to open file") 77 | return 78 | } 79 | defer fi.Close() 80 | 81 | if f.wr == nil { 82 | f.wr = bufio.NewWriterSize(fi, 4096*4) 83 | } else { 84 | f.wr.Reset(fi) 85 | } 86 | 87 | for cfg := range f.cache { 88 | _, _ = f.wr.Write([]byte(cfg + "\n")) 89 | } 90 | _ = f.wr.Flush() 91 | 92 | f.dump = false 93 | f.log.Info().Msgf("wrote %d config(s) to '%s'", len(f.cache), f.file) 94 | } 95 | 96 | type Stdout struct { 97 | sr model.Selector 98 | wr strings.Builder 99 | cache cache 100 | dump bool 101 | } 102 | 103 | func newStdout() *Stdout { 104 | return &Stdout{ 105 | sr: model.MustParseSelector("*"), 106 | cache: make(cache), 107 | } 108 | } 109 | 110 | func (s Stdout) String() string { 111 | return "stdout export" 112 | } 113 | 114 | func (s *Stdout) Export(ctx context.Context, out <-chan []model.Config) { 115 | const exportEvery = time.Second * 1 116 | tk := time.NewTicker(exportEvery) 117 | defer tk.Stop() 118 | 119 | for { 120 | select { 121 | case <-ctx.Done(): 122 | return 123 | case cfgs := <-out: 124 | s.process(cfgs) 125 | case <-tk.C: 126 | s.export() 127 | } 128 | } 129 | } 130 | 131 | func (s *Stdout) process(cfgs []model.Config) { 132 | for _, cfg := range cfgs { 133 | if !s.sr.Matches(cfg.Tags) { 134 | continue 135 | } 136 | if changed := s.cache.put(cfg); changed && !s.dump { 137 | s.dump = true 138 | } 139 | } 140 | } 141 | 142 | func (s *Stdout) export() { 143 | if !s.dump { 144 | return 145 | } 146 | s.dump = false 147 | defer s.wr.Reset() 148 | 149 | header := fmt.Sprintf("-----------------------CONFIGURATIONS(%d)-----------------------\n", len(s.cache)) 150 | s.wr.WriteString(header) 151 | for cfg := range s.cache { 152 | s.wr.Write([]byte(cfg + "\n")) 153 | } 154 | fmt.Println(s.wr.String()) 155 | } 156 | -------------------------------------------------------------------------------- /manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/netdata/sd/manager/config" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNew(t *testing.T) { 12 | tests := map[string]struct { 13 | prov ConfigProvider 14 | }{ 15 | "nil provider": {prov: nil}, 16 | "valid provider": {prov: &mockProvider{}}, 17 | } 18 | 19 | for name, test := range tests { 20 | t.Run(name, func(t *testing.T) { 21 | assert.NotNil(t, New(test.prov)) 22 | }) 23 | } 24 | } 25 | 26 | func TestManager_Run(t *testing.T) { 27 | tests := map[string]func() runSim{ 28 | "add pipeline": func() runSim { 29 | sim := runSim{ 30 | configs: []config.Config{ 31 | prepareConfig("source", "name"), 32 | }, 33 | expectedBeforeStop: []*mockPipeline{ 34 | {name: "name", started: true, stopped: false}, 35 | }, 36 | expectedAfterStop: []*mockPipeline{ 37 | {name: "name", started: true, stopped: true}, 38 | }, 39 | } 40 | return sim 41 | }, 42 | "remove pipeline": func() runSim { 43 | sim := runSim{ 44 | configs: []config.Config{ 45 | prepareConfig("source", "name"), 46 | prepareEmptyConfig("source"), 47 | }, 48 | expectedBeforeStop: []*mockPipeline{ 49 | {name: "name", started: true, stopped: true}, 50 | }, 51 | expectedAfterStop: []*mockPipeline{ 52 | {name: "name", started: true, stopped: true}, 53 | }, 54 | } 55 | return sim 56 | }, 57 | "several equal configs": func() runSim { 58 | sim := runSim{ 59 | configs: []config.Config{ 60 | prepareConfig("source", "name"), 61 | prepareConfig("source", "name"), 62 | prepareConfig("source", "name"), 63 | }, 64 | expectedBeforeStop: []*mockPipeline{ 65 | {name: "name", started: true, stopped: false}, 66 | }, 67 | expectedAfterStop: []*mockPipeline{ 68 | {name: "name", started: true, stopped: true}, 69 | }, 70 | } 71 | return sim 72 | }, 73 | "restart pipeline (same source, different config)": func() runSim { 74 | sim := runSim{ 75 | configs: []config.Config{ 76 | prepareConfig("source", "name1"), 77 | prepareConfig("source", "name2"), 78 | }, 79 | expectedBeforeStop: []*mockPipeline{ 80 | {name: "name1", started: true, stopped: true}, 81 | {name: "name2", started: true, stopped: false}, 82 | }, 83 | expectedAfterStop: []*mockPipeline{ 84 | {name: "name1", started: true, stopped: true}, 85 | {name: "name2", started: true, stopped: true}, 86 | }, 87 | } 88 | return sim 89 | }, 90 | "invalid pipeline config": func() runSim { 91 | sim := runSim{ 92 | configs: []config.Config{ 93 | prepareConfig("source", "invalid"), 94 | }, 95 | } 96 | return sim 97 | }, 98 | "handle invalid config for running pipeline": func() runSim { 99 | sim := runSim{ 100 | configs: []config.Config{ 101 | prepareConfig("source", "name"), 102 | prepareConfig("source", "invalid"), 103 | }, 104 | expectedBeforeStop: []*mockPipeline{ 105 | {name: "name", started: true, stopped: false}, 106 | }, 107 | expectedAfterStop: []*mockPipeline{ 108 | {name: "name", started: true, stopped: true}, 109 | }, 110 | } 111 | return sim 112 | }, 113 | } 114 | 115 | for name, sim := range tests { 116 | t.Run(name, func(t *testing.T) { sim().run(t) }) 117 | } 118 | } 119 | 120 | func prepareConfig(source, name string) config.Config { 121 | return config.Config{Pipeline: &config.PipelineConfig{Name: name}, Source: source} 122 | } 123 | 124 | func prepareEmptyConfig(source string) config.Config { 125 | return config.Config{Source: source} 126 | } 127 | -------------------------------------------------------------------------------- /pipeline/discovery/kubernetes/sim_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "testing" 7 | "time" 8 | 9 | "github.com/netdata/sd/pipeline/model" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "k8s.io/client-go/tools/cache" 14 | ) 15 | 16 | const ( 17 | startWaitTimeout = time.Second * 3 18 | finishWaitTimeout = time.Second * 5 19 | ) 20 | 21 | type discoverySim struct { 22 | discovery *Discovery 23 | runAfterSync func(ctx context.Context) 24 | sortBeforeVerify bool 25 | expectedGroups []model.Group 26 | } 27 | 28 | func (sim discoverySim) run(t *testing.T) []model.Group { 29 | t.Helper() 30 | require.NotNil(t, sim.discovery) 31 | require.NotEmpty(t, sim.expectedGroups) 32 | 33 | in, out := make(chan []model.Group), make(chan []model.Group) 34 | go sim.collectGroups(t, in, out) 35 | 36 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 37 | defer cancel() 38 | go sim.discovery.Discover(ctx, in) 39 | 40 | select { 41 | case <-sim.discovery.started: 42 | case <-time.After(startWaitTimeout): 43 | t.Fatalf("discovery %s filed to start in %s", sim.discovery.discoverers, startWaitTimeout) 44 | } 45 | 46 | synced := cache.WaitForCacheSync(ctx.Done(), sim.discovery.hasSynced) 47 | require.Truef(t, synced, "discovery %s failed to sync", sim.discovery.discoverers) 48 | 49 | if sim.runAfterSync != nil { 50 | sim.runAfterSync(ctx) 51 | } 52 | 53 | groups := <-out 54 | 55 | if sim.sortBeforeVerify { 56 | sortGroups(groups) 57 | } 58 | 59 | sim.verifyResult(t, groups) 60 | return groups 61 | } 62 | 63 | func (sim discoverySim) collectGroups(t *testing.T, in, out chan []model.Group) { 64 | var groups []model.Group 65 | loop: 66 | for { 67 | select { 68 | case inGroups := <-in: 69 | if groups = append(groups, inGroups...); len(groups) >= len(sim.expectedGroups) { 70 | break loop 71 | } 72 | case <-time.After(finishWaitTimeout): 73 | t.Logf("discovery %s timed out after %s, got %d groups, expected %d, some events are skipped", 74 | sim.discovery.discoverers, finishWaitTimeout, len(groups), len(sim.expectedGroups)) 75 | break loop 76 | } 77 | } 78 | out <- groups 79 | } 80 | 81 | func (sim discoverySim) verifyResult(t *testing.T, result []model.Group) { 82 | var expected, actual interface{} 83 | 84 | if len(sim.expectedGroups) == len(result) { 85 | expected = sim.expectedGroups 86 | actual = result 87 | } else { 88 | want := make(map[string]model.Group) 89 | for _, group := range sim.expectedGroups { 90 | want[group.Source()] = group 91 | } 92 | got := make(map[string]model.Group) 93 | for _, group := range result { 94 | got[group.Source()] = group 95 | } 96 | expected, actual = want, got 97 | } 98 | 99 | assert.Equal(t, expected, actual) 100 | } 101 | 102 | type hasSynced interface { 103 | hasSynced() bool 104 | } 105 | 106 | var ( 107 | _ hasSynced = &Discovery{} 108 | _ hasSynced = &Pod{} 109 | _ hasSynced = &Service{} 110 | ) 111 | 112 | func (d *Discovery) hasSynced() bool { 113 | for _, dd := range d.discoverers { 114 | v, ok := dd.(hasSynced) 115 | if !ok || !v.hasSynced() { 116 | return false 117 | } 118 | } 119 | return true 120 | } 121 | 122 | func (p *Pod) hasSynced() bool { 123 | return p.podInformer.HasSynced() && p.cmapInformer.HasSynced() && p.secretInformer.HasSynced() 124 | } 125 | 126 | func (s *Service) hasSynced() bool { 127 | return s.informer.HasSynced() 128 | } 129 | 130 | func sortGroups(groups []model.Group) { 131 | if len(groups) == 0 { 132 | return 133 | } 134 | sort.Slice(groups, func(i, j int) bool { return groups[i].Source() < groups[j].Source() }) 135 | } 136 | -------------------------------------------------------------------------------- /pipeline/pipeline.go: -------------------------------------------------------------------------------- 1 | package pipeline 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | "github.com/netdata/sd/pkg/log" 9 | 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | type Discoverer interface { 14 | Discover(ctx context.Context, in chan<- []model.Group) 15 | } 16 | 17 | type Tagger interface { 18 | Tag(model.Target) 19 | } 20 | 21 | type Builder interface { 22 | Build(model.Target) []model.Config 23 | } 24 | 25 | type Exporter interface { 26 | Export(ctx context.Context, out <-chan []model.Config) 27 | } 28 | 29 | type ( 30 | Pipeline struct { 31 | Discoverer 32 | Tagger 33 | Builder 34 | Exporter 35 | 36 | cache cache 37 | log zerolog.Logger 38 | } 39 | cache map[string]groupCache // source:hash:configs 40 | groupCache map[uint64][]model.Config 41 | ) 42 | 43 | func New(discoverer Discoverer, tagger Tagger, builder Builder, exporter Exporter) *Pipeline { 44 | return &Pipeline{ 45 | Discoverer: discoverer, 46 | Tagger: tagger, 47 | Builder: builder, 48 | Exporter: exporter, 49 | cache: make(cache), 50 | log: log.New("pipeline"), 51 | } 52 | } 53 | 54 | func (p *Pipeline) Run(ctx context.Context) { 55 | p.log.Info().Msg("instance is started") 56 | defer p.log.Info().Msg("instance is stopped") 57 | 58 | var wg sync.WaitGroup 59 | disc := make(chan []model.Group) 60 | exp := make(chan []model.Config) 61 | 62 | wg.Add(1) 63 | go func() { defer wg.Done(); p.Discover(ctx, disc) }() 64 | 65 | wg.Add(1) 66 | go func() { defer wg.Done(); p.run(ctx, disc, exp) }() 67 | 68 | wg.Add(1) 69 | go func() { defer wg.Done(); p.Export(ctx, exp) }() 70 | 71 | wg.Wait() 72 | <-ctx.Done() 73 | } 74 | 75 | func (p *Pipeline) run(ctx context.Context, disc chan []model.Group, export chan []model.Config) { 76 | for { 77 | select { 78 | case <-ctx.Done(): 79 | return 80 | case groups := <-disc: 81 | if configs := p.process(groups); len(configs) > 0 { 82 | select { 83 | case <-ctx.Done(): 84 | case export <- configs: 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | func (p *Pipeline) process(groups []model.Group) (configs []model.Config) { 92 | p.log.Info().Msgf("received '%d' group(s)", len(groups)) 93 | 94 | for _, group := range groups { 95 | p.log.Info().Msgf("processing group '%s' with %d target(s)", group.Source(), len(group.Targets())) 96 | 97 | if len(group.Targets()) == 0 { 98 | if remove := p.handleEmpty(group); len(remove) > 0 { 99 | p.log.Info().Msgf("group '%s': stale config(s) %d", group.Source(), len(remove)) 100 | 101 | configs = append(configs, remove...) 102 | } 103 | } else { 104 | if add, remove := p.handleNotEmpty(group); len(add) > 0 || len(remove) > 0 { 105 | p.log.Info().Msgf("group '%s': new/stale config(s) %d/%d", group.Source(), len(add), len(remove)) 106 | 107 | configs = append(configs, append(add, remove...)...) 108 | } 109 | } 110 | } 111 | return configs 112 | } 113 | 114 | func (p *Pipeline) handleEmpty(group model.Group) (remove []model.Config) { 115 | grpCache, exist := p.cache[group.Source()] 116 | if !exist { 117 | return 118 | } 119 | delete(p.cache, group.Source()) 120 | 121 | for hash, cfgs := range grpCache { 122 | delete(grpCache, hash) 123 | remove = append(remove, cfgs...) 124 | } 125 | 126 | return stale(remove) 127 | } 128 | 129 | func (p *Pipeline) handleNotEmpty(group model.Group) (add, remove []model.Config) { 130 | grpCache, exist := p.cache[group.Source()] 131 | if !exist { 132 | grpCache = make(map[uint64][]model.Config) 133 | p.cache[group.Source()] = grpCache 134 | } 135 | 136 | seen := make(map[uint64]bool) 137 | for _, target := range group.Targets() { 138 | if target == nil { 139 | continue 140 | } 141 | seen[target.Hash()] = true 142 | 143 | if _, ok := grpCache[target.Hash()]; ok { 144 | continue 145 | } 146 | 147 | p.Tag(target) 148 | cfgs := p.Build(target) 149 | 150 | grpCache[target.Hash()] = cfgs 151 | add = append(add, cfgs...) 152 | } 153 | 154 | if !exist { 155 | return 156 | } 157 | 158 | for hash, cfgs := range grpCache { 159 | if !seen[hash] { 160 | delete(grpCache, hash) 161 | remove = append(remove, stale(cfgs)...) 162 | } 163 | } 164 | return add, remove 165 | } 166 | 167 | func stale(configs []model.Config) []model.Config { 168 | for i := range configs { 169 | configs[i].Stale = true 170 | } 171 | return configs 172 | } 173 | -------------------------------------------------------------------------------- /pipeline/discovery/manager_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/netdata/sd/pipeline/model" 10 | ) 11 | 12 | func TestNew(t *testing.T) { 13 | 14 | } 15 | 16 | func TestManager_Discover(t *testing.T) { 17 | tests := map[string]func() discoverySim{ 18 | "2 discoverers unique groups with delayed collect": func() discoverySim { 19 | const numGroups, numTargets = 2, 2 20 | d1 := prepareMockDiscoverer("test1", numGroups, numTargets) 21 | d2 := prepareMockDiscoverer("test2", numGroups, numTargets) 22 | mgr := prepareManager(d1, d2) 23 | expected := combineGroups(d1.groups, d2.groups) 24 | 25 | sim := discoverySim{ 26 | mgr: mgr, 27 | collectDelay: mgr.sendEvery + time.Second, 28 | expectedGroups: expected, 29 | } 30 | return sim 31 | }, 32 | "2 discoverers unique groups": func() discoverySim { 33 | const numGroups, numTargets = 2, 2 34 | d1 := prepareMockDiscoverer("test1", numGroups, numTargets) 35 | d2 := prepareMockDiscoverer("test2", numGroups, numTargets) 36 | mgr := prepareManager(d1, d2) 37 | expected := combineGroups(d1.groups, d2.groups) 38 | 39 | sim := discoverySim{ 40 | mgr: mgr, 41 | expectedGroups: expected, 42 | } 43 | return sim 44 | }, 45 | "2 discoverers same groups": func() discoverySim { 46 | const numGroups, numTargets = 2, 2 47 | d1 := prepareMockDiscoverer("test1", numGroups, numTargets) 48 | mgr := prepareManager(d1, d1) 49 | expected := combineGroups(d1.groups) 50 | 51 | sim := discoverySim{ 52 | mgr: mgr, 53 | expectedGroups: expected, 54 | } 55 | return sim 56 | }, 57 | "2 discoverers empty groups": func() discoverySim { 58 | const numGroups, numTargets = 1, 0 59 | d1 := prepareMockDiscoverer("test1", numGroups, numTargets) 60 | d2 := prepareMockDiscoverer("test2", numGroups, numTargets) 61 | mgr := prepareManager(d1, d2) 62 | expected := combineGroups(d1.groups, d2.groups) 63 | 64 | sim := discoverySim{ 65 | mgr: mgr, 66 | expectedGroups: expected, 67 | } 68 | return sim 69 | }, 70 | "2 discoverers nil groups": func() discoverySim { 71 | const numGroups, numTargets = 0, 0 72 | d1 := prepareMockDiscoverer("test1", numGroups, numTargets) 73 | d2 := prepareMockDiscoverer("test2", numGroups, numTargets) 74 | mgr := prepareManager(d1, d2) 75 | 76 | sim := discoverySim{ 77 | mgr: mgr, 78 | expectedGroups: nil, 79 | } 80 | return sim 81 | }, 82 | } 83 | 84 | for name, sim := range tests { 85 | t.Run(name, func(t *testing.T) { sim().run(t) }) 86 | } 87 | } 88 | 89 | func prepareMockDiscoverer(source string, groups, targets int) mockDiscoverer { 90 | d := mockDiscoverer{} 91 | 92 | for i := 0; i < groups; i++ { 93 | group := mockGroup{ 94 | source: fmt.Sprintf("%s_group_%d", source, i+1), 95 | } 96 | for j := 0; j < targets; j++ { 97 | group.targets = append(group.targets, 98 | mockTarget{Name: fmt.Sprintf("%s_group_%d_target_%d", source, i+1, j+1)}) 99 | } 100 | d.groups = append(d.groups, group) 101 | } 102 | return d 103 | } 104 | 105 | func prepareManager(discoverers ...discoverer) *Manager { 106 | mgr := &Manager{ 107 | send: make(chan struct{}, 1), 108 | sendEvery: 2 * time.Second, 109 | discoverers: discoverers, 110 | cache: newCache(), 111 | } 112 | return mgr 113 | } 114 | 115 | type mockDiscoverer struct { 116 | groups []model.Group 117 | } 118 | 119 | func (md mockDiscoverer) Discover(ctx context.Context, out chan<- []model.Group) { 120 | for { 121 | select { 122 | case <-ctx.Done(): 123 | return 124 | case out <- md.groups: 125 | return 126 | } 127 | } 128 | } 129 | 130 | type mockGroup struct { 131 | targets []model.Target 132 | source string 133 | } 134 | 135 | func (mg mockGroup) Targets() []model.Target { return mg.targets } 136 | func (mg mockGroup) Source() string { return mg.source } 137 | 138 | type mockTarget struct { 139 | Name string 140 | } 141 | 142 | func (mt mockTarget) Tags() model.Tags { return model.Tags{} } 143 | func (mt mockTarget) TUID() string { return "" } 144 | func (mt mockTarget) Hash() uint64 { return 0 } 145 | 146 | func combineGroups(groups ...[]model.Group) (combined []model.Group) { 147 | for _, set := range groups { 148 | combined = append(combined, set...) 149 | } 150 | return combined 151 | } 152 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/netdata/sd/manager/config" 8 | "github.com/netdata/sd/pipeline" 9 | "github.com/netdata/sd/pipeline/build" 10 | "github.com/netdata/sd/pipeline/discovery" 11 | "github.com/netdata/sd/pipeline/export" 12 | "github.com/netdata/sd/pipeline/tag" 13 | "github.com/netdata/sd/pkg/log" 14 | 15 | "github.com/rs/zerolog" 16 | ) 17 | 18 | type ( 19 | Manager struct { 20 | prov ConfigProvider 21 | factory factory 22 | cache map[string]uint64 23 | pipelines map[string]func() 24 | log zerolog.Logger 25 | } 26 | ConfigProvider interface { 27 | Run(ctx context.Context) 28 | Configs() chan []config.Config 29 | } 30 | sdPipeline interface { 31 | Run(ctx context.Context) 32 | } 33 | factory interface { 34 | create(cfg config.PipelineConfig) (sdPipeline, error) 35 | } 36 | factoryFunc func(cfg config.PipelineConfig) (sdPipeline, error) 37 | ) 38 | 39 | func (f factoryFunc) create(cfg config.PipelineConfig) (sdPipeline, error) { return f(cfg) } 40 | 41 | func New(provider ConfigProvider) *Manager { 42 | return &Manager{ 43 | prov: provider, 44 | factory: factoryFunc(newPipeline), 45 | cache: make(map[string]uint64), 46 | pipelines: make(map[string]func()), 47 | log: log.New("pipeline manager"), 48 | } 49 | } 50 | 51 | func (m *Manager) Run(ctx context.Context) { 52 | m.log.Info().Msg("instance is started") 53 | defer m.log.Info().Msg("instance is stopped") 54 | defer m.cleanup() 55 | 56 | var wg sync.WaitGroup 57 | 58 | wg.Add(1) 59 | go func() { defer wg.Done(); m.prov.Run(ctx) }() 60 | 61 | wg.Add(1) 62 | go func() { defer wg.Done(); m.run(ctx) }() 63 | 64 | wg.Wait() 65 | <-ctx.Done() 66 | } 67 | 68 | func (m *Manager) cleanup() { 69 | for _, stop := range m.pipelines { 70 | stop() 71 | } 72 | } 73 | 74 | func (m *Manager) run(ctx context.Context) { 75 | for { 76 | select { 77 | case <-ctx.Done(): 78 | return 79 | case cfgs := <-m.prov.Configs(): 80 | for _, cfg := range cfgs { 81 | select { 82 | case <-ctx.Done(): 83 | return 84 | default: 85 | m.process(ctx, cfg) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | func (m *Manager) process(ctx context.Context, cfg config.Config) { 93 | if cfg.Source == "" { 94 | return 95 | } 96 | 97 | if cfg.Pipeline == nil { 98 | delete(m.cache, cfg.Source) 99 | m.handleRemoveConfig(cfg) 100 | return 101 | } 102 | 103 | if hash, ok := m.cache[cfg.Source]; !ok || hash != cfg.Pipeline.Hash() { 104 | m.cache[cfg.Source] = cfg.Pipeline.Hash() 105 | m.handleNewConfig(ctx, cfg) 106 | } 107 | } 108 | 109 | func (m *Manager) handleRemoveConfig(cfg config.Config) { 110 | if stop, ok := m.pipelines[cfg.Source]; ok { 111 | m.log.Info().Msgf("received an empty config, stopping the pipeline ('%s')", cfg.Source) 112 | delete(m.pipelines, cfg.Source) 113 | stop() 114 | } 115 | } 116 | 117 | func (m *Manager) handleNewConfig(ctx context.Context, cfg config.Config) { 118 | p, err := m.factory.create(*cfg.Pipeline) 119 | if err != nil { 120 | if _, ok := m.pipelines[cfg.Source]; ok { 121 | m.log.Warn().Err(err).Msgf("unable to create a pipeline, will keep using old config ('%s')", 122 | cfg.Source) 123 | } else { 124 | m.log.Warn().Err(err).Msgf("unable to create a pipeline ('%s')", cfg.Source) 125 | } 126 | return 127 | } 128 | 129 | if stop, ok := m.pipelines[cfg.Source]; ok { 130 | m.log.Info().Msgf("received an updated config, restarting the pipeline ('%s')", cfg.Source) 131 | stop() 132 | } else { 133 | m.log.Info().Msgf("received a new config, starting a new pipeline ('%s')", cfg.Source) 134 | } 135 | 136 | var wg sync.WaitGroup 137 | pipelineCtx, cancel := context.WithCancel(ctx) 138 | 139 | wg.Add(1) 140 | go func() { defer wg.Done(); p.Run(pipelineCtx) }() 141 | stop := func() { cancel(); wg.Wait() } 142 | 143 | m.pipelines[cfg.Source] = stop 144 | } 145 | 146 | func newPipeline(cfg config.PipelineConfig) (sdPipeline, error) { 147 | exporter, err := export.New(cfg.Export) 148 | if err != nil { 149 | return nil, err 150 | } 151 | builder, err := build.New(cfg.Build) 152 | if err != nil { 153 | return nil, err 154 | } 155 | tagger, err := tag.New(cfg.Tag) 156 | if err != nil { 157 | return nil, err 158 | } 159 | discoverer, err := discovery.New(cfg.Discovery) 160 | if err != nil { 161 | return nil, err 162 | } 163 | return pipeline.New(discoverer, tagger, builder, exporter), nil 164 | } 165 | -------------------------------------------------------------------------------- /pipeline/model/selector.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type Selector interface { 10 | Matches(Tags) bool 11 | } 12 | 13 | type ( 14 | exactSelector string 15 | trueSelector struct{} 16 | negSelector struct{ Selector } 17 | orSelector struct{ lhs, rhs Selector } 18 | andSelector struct{ lhs, rhs Selector } 19 | ) 20 | 21 | func (s exactSelector) Matches(tags Tags) bool { _, ok := tags[string(s)]; return ok } 22 | func (s trueSelector) Matches(_ Tags) bool { return true } 23 | func (s negSelector) Matches(tags Tags) bool { return !s.Selector.Matches(tags) } 24 | func (s orSelector) Matches(tags Tags) bool { return s.lhs.Matches(tags) || s.rhs.Matches(tags) } 25 | func (s andSelector) Matches(tags Tags) bool { return s.lhs.Matches(tags) && s.rhs.Matches(tags) } 26 | 27 | func (s exactSelector) String() string { return "{" + string(s) + "}" } 28 | func (s negSelector) String() string { return "{!" + stringify(s.Selector) + "}" } 29 | func (s trueSelector) String() string { return "{*}" } 30 | func (s orSelector) String() string { return "{" + stringify(s.lhs) + "|" + stringify(s.rhs) + "}" } 31 | func (s andSelector) String() string { return "{" + stringify(s.lhs) + ", " + stringify(s.rhs) + "}" } 32 | func stringify(sr Selector) string { return strings.Trim(fmt.Sprintf("%s", sr), "{}") } 33 | 34 | func ParseSelector(line string) (sr Selector, err error) { 35 | words := strings.Fields(line) 36 | if len(words) == 0 { 37 | return trueSelector{}, nil 38 | } 39 | 40 | var srs []Selector 41 | for _, word := range words { 42 | if idx := strings.IndexByte(word, '|'); idx > 0 { 43 | sr, err = parseOrSelectorWord(word) 44 | } else { 45 | sr, err = parseSingleSelectorWord(word) 46 | } 47 | if err != nil { 48 | return nil, fmt.Errorf("selector '%s' contains selector '%s' with forbidden symbol", line, word) 49 | } 50 | srs = append(srs, sr) 51 | } 52 | 53 | switch len(srs) { 54 | case 0: 55 | return trueSelector{}, nil 56 | case 1: 57 | return srs[0], nil 58 | default: 59 | return newAndSelector(srs[0], srs[1], srs[2:]...), nil 60 | } 61 | } 62 | 63 | func MustParseSelector(line string) Selector { 64 | sr, err := ParseSelector(line) 65 | if err != nil { 66 | panic(fmt.Sprintf("selector '%s' parse error: %v", line, err)) 67 | } 68 | return sr 69 | } 70 | 71 | func parseOrSelectorWord(orWord string) (sr Selector, err error) { 72 | var srs []Selector 73 | for _, word := range strings.Split(orWord, "|") { 74 | if sr, err = parseSingleSelectorWord(word); err != nil { 75 | return nil, err 76 | } 77 | srs = append(srs, sr) 78 | } 79 | switch len(srs) { 80 | case 0: 81 | return trueSelector{}, nil 82 | case 1: 83 | return srs[0], nil 84 | default: 85 | return newOrSelector(srs[0], srs[1], srs[2:]...), nil 86 | } 87 | } 88 | 89 | func parseSingleSelectorWord(word string) (Selector, error) { 90 | if len(word) == 0 { 91 | return nil, errors.New("empty word") 92 | } 93 | neg := word[0] == '!' 94 | if neg { 95 | word = word[1:] 96 | } 97 | if len(word) == 0 { 98 | return nil, errors.New("empty word") 99 | } 100 | if word != "*" && !isSelectorWordValid(word) { 101 | return nil, errors.New("forbidden symbol") 102 | } 103 | 104 | var sr Selector 105 | switch word { 106 | case "*": 107 | sr = trueSelector{} 108 | default: 109 | sr = exactSelector(word) 110 | } 111 | if neg { 112 | return negSelector{sr}, nil 113 | } 114 | return sr, nil 115 | } 116 | 117 | func newAndSelector(lhs, rhs Selector, others ...Selector) Selector { 118 | m := andSelector{lhs: lhs, rhs: rhs} 119 | switch len(others) { 120 | case 0: 121 | return m 122 | default: 123 | return newAndSelector(m, others[0], others[1:]...) 124 | } 125 | } 126 | 127 | func newOrSelector(lhs, rhs Selector, others ...Selector) Selector { 128 | m := orSelector{lhs: lhs, rhs: rhs} 129 | switch len(others) { 130 | case 0: 131 | return m 132 | default: 133 | return newOrSelector(m, others[0], others[1:]...) 134 | } 135 | } 136 | 137 | func isSelectorWordValid(word string) bool { 138 | // valid: 139 | // * 140 | // ^[a-zA-Z][a-zA-Z0-9=_.]*$ 141 | if len(word) == 0 { 142 | return false 143 | } 144 | if word == "*" { 145 | return true 146 | } 147 | for i, b := range word { 148 | switch { 149 | default: 150 | return false 151 | case b >= 'a' && b <= 'z': 152 | case b >= 'A' && b <= 'Z': 153 | case b >= '0' && b <= '9' && i > 0: 154 | case (b == '=' || b == '_' || b == '.') && i > 0: 155 | } 156 | } 157 | return true 158 | } 159 | -------------------------------------------------------------------------------- /pipeline/discovery/kubernetes/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/netdata/sd/pipeline/model" 9 | "github.com/netdata/sd/pkg/k8s" 10 | 11 | "github.com/stretchr/testify/assert" 12 | apiv1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/client-go/kubernetes" 16 | "k8s.io/client-go/kubernetes/fake" 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | _ = os.Setenv(envNodeName, "m01") 21 | _ = os.Setenv(k8s.EnvFakeClient, "true") 22 | code := m.Run() 23 | _ = os.Unsetenv(envNodeName) 24 | _ = os.Unsetenv(k8s.EnvFakeClient) 25 | os.Exit(code) 26 | } 27 | 28 | func TestNewDiscovery(t *testing.T) { 29 | tests := map[string]struct { 30 | cfg Config 31 | wantErr bool 32 | }{ 33 | "role pod and local mode": {cfg: Config{Role: RolePod, Tags: "k8s", LocalMode: true}}, 34 | "role service and local mode": {cfg: Config{Role: RoleService, Tags: "k8s", LocalMode: true}}, 35 | "empty config": {wantErr: true}, 36 | "invalid role": {cfg: Config{Role: "invalid"}, wantErr: true}, 37 | "lack of tags": {cfg: Config{Role: RolePod}, wantErr: true}, 38 | } 39 | for name, test := range tests { 40 | t.Run(name, func(t *testing.T) { 41 | discovery, err := NewDiscovery(test.cfg) 42 | 43 | if test.wantErr { 44 | assert.Error(t, err) 45 | assert.Nil(t, discovery) 46 | } else { 47 | assert.NoError(t, err) 48 | assert.NotNil(t, discovery) 49 | if test.cfg.LocalMode && test.cfg.Role == RolePod { 50 | assert.Contains(t, discovery.selectorField, "spec.nodeName=m01") 51 | } 52 | if test.cfg.LocalMode && test.cfg.Role != RolePod { 53 | assert.Empty(t, discovery.selectorField) 54 | } 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestDiscovery_Discover(t *testing.T) { 61 | const prod = "prod" 62 | const dev = "dev" 63 | prodNamespace := newNamespace(prod) 64 | devNamespace := newNamespace(dev) 65 | 66 | tests := map[string]func() discoverySim{ 67 | "multiple namespaces pod discovery": func() discoverySim { 68 | httpdProd, nginxProd := newHTTPDPod(), newNGINXPod() 69 | httpdProd.Namespace = prod 70 | nginxProd.Namespace = prod 71 | 72 | httpdDev, nginxDev := newHTTPDPod(), newNGINXPod() 73 | httpdDev.Namespace = dev 74 | nginxDev.Namespace = dev 75 | 76 | discovery, _ := prepareDiscovery( 77 | RolePod, 78 | []string{prod, dev}, 79 | prodNamespace, devNamespace, httpdProd, nginxProd, httpdDev, nginxDev) 80 | 81 | sim := discoverySim{ 82 | discovery: discovery, 83 | sortBeforeVerify: true, 84 | expectedGroups: []model.Group{ 85 | preparePodGroup(httpdDev), 86 | preparePodGroup(nginxDev), 87 | preparePodGroup(httpdProd), 88 | preparePodGroup(nginxProd), 89 | }, 90 | } 91 | return sim 92 | }, 93 | "multiple namespaces ClusterIP service discovery": func() discoverySim { 94 | httpdProd, nginxProd := newHTTPDClusterIPService(), newNGINXClusterIPService() 95 | httpdProd.Namespace = prod 96 | nginxProd.Namespace = prod 97 | 98 | httpdDev, nginxDev := newHTTPDClusterIPService(), newNGINXClusterIPService() 99 | httpdDev.Namespace = dev 100 | nginxDev.Namespace = dev 101 | 102 | discovery, _ := prepareDiscovery( 103 | RoleService, 104 | []string{prod, dev}, 105 | prodNamespace, devNamespace, httpdProd, nginxProd, httpdDev, nginxDev) 106 | 107 | sim := discoverySim{ 108 | discovery: discovery, 109 | sortBeforeVerify: true, 110 | expectedGroups: []model.Group{ 111 | prepareSvcGroup(httpdDev), 112 | prepareSvcGroup(nginxDev), 113 | prepareSvcGroup(httpdProd), 114 | prepareSvcGroup(nginxProd), 115 | }, 116 | } 117 | return sim 118 | }, 119 | } 120 | 121 | for name, sim := range tests { 122 | t.Run(name, func(t *testing.T) { sim().run(t) }) 123 | } 124 | } 125 | 126 | var discoveryTags model.Tags = map[string]struct{}{"k8s": {}} 127 | 128 | func prepareAllNsDiscovery(role string, objects ...runtime.Object) (*Discovery, kubernetes.Interface) { 129 | return prepareDiscovery(role, []string{apiv1.NamespaceAll}, objects...) 130 | } 131 | 132 | func prepareDiscovery(role string, namespaces []string, objects ...runtime.Object) (*Discovery, kubernetes.Interface) { 133 | clientset := fake.NewSimpleClientset(objects...) 134 | discovery := &Discovery{ 135 | tags: discoveryTags, 136 | namespaces: namespaces, 137 | role: role, 138 | selectorLabel: "", 139 | selectorField: "", 140 | client: clientset, 141 | discoverers: nil, 142 | started: make(chan struct{}), 143 | } 144 | return discovery, clientset 145 | } 146 | 147 | func newNamespace(name string) *apiv1.Namespace { 148 | return &apiv1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} 149 | } 150 | 151 | func mustCalcHash(target interface{}) uint64 { 152 | hash, err := calcHash(target) 153 | if err != nil { 154 | panic(fmt.Sprintf("hash calculation: %v", err)) 155 | } 156 | return hash 157 | } 158 | -------------------------------------------------------------------------------- /pipeline/pipeline_test.go: -------------------------------------------------------------------------------- 1 | package pipeline 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | 9 | "github.com/ilyam8/hashstructure" 10 | ) 11 | 12 | func TestPipeline_Run(t *testing.T) { 13 | tests := map[string]func() pipelineSim{ 14 | "new group with no targets": func() pipelineSim { 15 | g1 := mockGroup{source: "s1"} 16 | 17 | sim := pipelineSim{ 18 | discoveredGroups: []model.Group{g1}, 19 | expectedCacheItems: 0, 20 | } 21 | return sim 22 | }, 23 | "new group with targets": func() pipelineSim { 24 | t1 := mockTarget{Name: "t1"} 25 | t2 := mockTarget{Name: "t2"} 26 | g1 := mockGroup{targets: []model.Target{t1, t2}, source: "s1"} 27 | 28 | sim := pipelineSim{ 29 | discoveredGroups: []model.Group{g1}, 30 | expectedTag: []model.Target{t1, t2}, 31 | expectedBuild: []model.Target{t1, t2}, 32 | expectedExport: []model.Config{{Conf: "t1"}, {Conf: "t2"}}, 33 | expectedCacheItems: 1, 34 | } 35 | return sim 36 | }, 37 | "existing group with same targets": func() pipelineSim { 38 | t1 := mockTarget{Name: "t1"} 39 | t2 := mockTarget{Name: "t2"} 40 | g1 := mockGroup{targets: []model.Target{t1, t2}, source: "s1"} 41 | 42 | sim := pipelineSim{ 43 | discoveredGroups: []model.Group{g1, g1}, 44 | expectedTag: []model.Target{t1, t2}, 45 | expectedBuild: []model.Target{t1, t2}, 46 | expectedExport: []model.Config{{Conf: "t1"}, {Conf: "t2"}}, 47 | expectedCacheItems: 1, 48 | } 49 | return sim 50 | }, 51 | "existing group with no targets": func() pipelineSim { 52 | t1 := mockTarget{Name: "t1"} 53 | t2 := mockTarget{Name: "t2"} 54 | g1 := mockGroup{targets: []model.Target{t1, t2}, source: "s1"} 55 | g2 := mockGroup{source: "s1"} 56 | 57 | sim := pipelineSim{ 58 | discoveredGroups: []model.Group{g1, g2}, 59 | expectedTag: []model.Target{t1, t2}, 60 | expectedBuild: []model.Target{t1, t2}, 61 | expectedExport: []model.Config{ 62 | {Conf: "t1"}, {Conf: "t2"}, {Conf: "t1", Stale: true}, {Conf: "t2", Stale: true}, 63 | }, 64 | expectedCacheItems: 0, 65 | } 66 | return sim 67 | }, 68 | "existing group with old and new targets": func() pipelineSim { 69 | t1 := mockTarget{Name: "t1"} 70 | t2 := mockTarget{Name: "t2"} 71 | t3 := mockTarget{Name: "t3"} 72 | g1 := mockGroup{targets: []model.Target{t1, t2}, source: "s1"} 73 | g2 := mockGroup{targets: []model.Target{t1, t3}, source: "s1"} 74 | 75 | sim := pipelineSim{ 76 | discoveredGroups: []model.Group{g1, g2}, 77 | expectedTag: []model.Target{t1, t2, t3}, 78 | expectedBuild: []model.Target{t1, t2, t3}, 79 | expectedExport: []model.Config{ 80 | {Conf: "t1"}, {Conf: "t2"}, {Conf: "t3"}, {Conf: "t2", Stale: true}}, 81 | expectedCacheItems: 1, 82 | } 83 | return sim 84 | }, 85 | "existing group with new targets only": func() pipelineSim { 86 | t1 := mockTarget{Name: "t1"} 87 | t2 := mockTarget{Name: "t2"} 88 | t3 := mockTarget{Name: "t3"} 89 | g1 := mockGroup{targets: []model.Target{t1, t2}, source: "s1"} 90 | g2 := mockGroup{targets: []model.Target{t3}, source: "s1"} 91 | 92 | sim := pipelineSim{ 93 | discoveredGroups: []model.Group{g1, g2}, 94 | expectedTag: []model.Target{t1, t2, t3}, 95 | expectedBuild: []model.Target{t1, t2, t3}, 96 | expectedExport: []model.Config{ 97 | {Conf: "t1"}, {Conf: "t2"}, {Conf: "t3"}, 98 | {Conf: "t1", Stale: true}, {Conf: "t2", Stale: true}}, 99 | expectedCacheItems: 1, 100 | } 101 | return sim 102 | }, 103 | } 104 | 105 | for name, sim := range tests { 106 | t.Run(name, func(t *testing.T) { sim().run(t) }) 107 | } 108 | } 109 | 110 | type ( 111 | mockDiscoverer struct { 112 | send []model.Group 113 | } 114 | mockTagger struct { 115 | seen []model.Target 116 | } 117 | mockBuilder struct { 118 | seen []model.Target 119 | } 120 | mockExporter struct { 121 | seen []model.Config 122 | } 123 | ) 124 | 125 | func (d mockDiscoverer) Discover(ctx context.Context, in chan<- []model.Group) { 126 | select { 127 | case <-ctx.Done(): 128 | case in <- d.send: 129 | } 130 | <-ctx.Done() 131 | } 132 | 133 | func (t *mockTagger) Tag(target model.Target) { 134 | t.seen = append(t.seen, target) 135 | } 136 | 137 | func (b *mockBuilder) Build(target model.Target) []model.Config { 138 | b.seen = append(b.seen, target) 139 | return []model.Config{{Conf: target.TUID()}} 140 | } 141 | 142 | func (e *mockExporter) Export(ctx context.Context, out <-chan []model.Config) { 143 | select { 144 | case <-ctx.Done(): 145 | case cfgs := <-out: 146 | e.seen = append(e.seen, cfgs...) 147 | } 148 | <-ctx.Done() 149 | } 150 | 151 | type ( 152 | mockGroup struct { 153 | targets []model.Target 154 | source string 155 | } 156 | mockTarget struct { 157 | Name string 158 | } 159 | ) 160 | 161 | func (mg mockGroup) Targets() []model.Target { return mg.targets } 162 | func (mg mockGroup) Source() string { return mg.source } 163 | 164 | func (mt mockTarget) Tags() model.Tags { return nil } 165 | func (mt mockTarget) TUID() string { return mt.Name } 166 | func (mt mockTarget) Hash() uint64 { h, _ := hashstructure.Hash(mt, nil); return h } 167 | -------------------------------------------------------------------------------- /pipeline/discovery/kubernetes/service.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/netdata/sd/pipeline/model" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/rs/zerolog" 14 | apiv1 "k8s.io/api/core/v1" 15 | "k8s.io/client-go/tools/cache" 16 | "k8s.io/client-go/util/workqueue" 17 | ) 18 | 19 | type ( 20 | serviceGroup struct { 21 | targets []model.Target 22 | source string 23 | } 24 | ServiceTarget struct { 25 | model.Base `hash:"ignore"` 26 | hash uint64 27 | tuid string 28 | Address string 29 | 30 | Namespace string 31 | Name string 32 | Annotations map[string]interface{} 33 | Labels map[string]interface{} 34 | 35 | Port string 36 | PortName string 37 | PortProtocol string 38 | ClusterIP string 39 | ExternalName string 40 | Type string 41 | } 42 | ) 43 | 44 | func (st ServiceTarget) Hash() uint64 { return st.hash } 45 | func (st ServiceTarget) TUID() string { return st.tuid } 46 | 47 | func (sg serviceGroup) Source() string { return sg.source } 48 | func (sg serviceGroup) Targets() []model.Target { return sg.targets } 49 | 50 | type Service struct { 51 | informer cache.SharedInformer 52 | queue *workqueue.Type 53 | log zerolog.Logger 54 | } 55 | 56 | func NewService(inf cache.SharedInformer) *Service { 57 | queue := workqueue.NewWithConfig(workqueue.QueueConfig{Name: "service"}) 58 | inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ 59 | AddFunc: func(obj interface{}) { enqueue(queue, obj) }, 60 | UpdateFunc: func(_, obj interface{}) { enqueue(queue, obj) }, 61 | DeleteFunc: func(obj interface{}) { enqueue(queue, obj) }, 62 | }) 63 | 64 | return &Service{ 65 | informer: inf, 66 | queue: queue, 67 | log: log.New("k8s service discovery"), 68 | } 69 | } 70 | 71 | func (s Service) String() string { 72 | return fmt.Sprintf("k8s %s discovery", RoleService) 73 | } 74 | 75 | func (s *Service) Discover(ctx context.Context, ch chan<- []model.Group) { 76 | s.log.Info().Msg("instance is started") 77 | defer s.log.Info().Msg("instance is stopped") 78 | defer s.queue.ShutDown() 79 | 80 | go s.informer.Run(ctx.Done()) 81 | 82 | if !cache.WaitForCacheSync(ctx.Done(), s.informer.HasSynced) { 83 | s.log.Error().Msg("failed to sync caches") 84 | return 85 | } 86 | 87 | go s.run(ctx, ch) 88 | <-ctx.Done() 89 | } 90 | 91 | func (s *Service) run(ctx context.Context, ch chan<- []model.Group) { 92 | for { 93 | item, shutdown := s.queue.Get() 94 | if shutdown { 95 | return 96 | } 97 | 98 | func() { 99 | defer s.queue.Done(item) 100 | 101 | key := item.(string) 102 | namespace, name, err := cache.SplitMetaNamespaceKey(key) 103 | if err != nil { 104 | return 105 | } 106 | 107 | item, exists, err := s.informer.GetStore().GetByKey(key) 108 | if err != nil { 109 | return 110 | } 111 | 112 | if !exists { 113 | group := &serviceGroup{source: serviceSourceFromNsName(namespace, name)} 114 | send(ctx, ch, group) 115 | return 116 | } 117 | 118 | svc, err := toService(item) 119 | if err != nil { 120 | return 121 | } 122 | 123 | group := s.buildGroup(svc) 124 | send(ctx, ch, group) 125 | }() 126 | } 127 | } 128 | 129 | func (s Service) buildGroup(svc *apiv1.Service) model.Group { 130 | // TODO: headless service? 131 | if svc.Spec.ClusterIP == "" || len(svc.Spec.Ports) == 0 { 132 | return &serviceGroup{ 133 | source: serviceSource(svc), 134 | } 135 | } 136 | return &serviceGroup{ 137 | source: serviceSource(svc), 138 | targets: s.buildTargets(svc), 139 | } 140 | } 141 | 142 | func (s Service) buildTargets(svc *apiv1.Service) (targets []model.Target) { 143 | for _, port := range svc.Spec.Ports { 144 | portNum := strconv.FormatInt(int64(port.Port), 10) 145 | target := &ServiceTarget{ 146 | tuid: serviceTUID(svc, port), 147 | Address: net.JoinHostPort(svc.Name+"."+svc.Namespace+".svc", portNum), 148 | Namespace: svc.Namespace, 149 | Name: svc.Name, 150 | Annotations: toMapInterface(svc.Annotations), 151 | Labels: toMapInterface(svc.Labels), 152 | Port: portNum, 153 | PortName: port.Name, 154 | PortProtocol: string(port.Protocol), 155 | ClusterIP: svc.Spec.ClusterIP, 156 | ExternalName: svc.Spec.ExternalName, 157 | Type: string(svc.Spec.Type), 158 | } 159 | hash, err := calcHash(target) 160 | if err != nil { 161 | continue 162 | } 163 | target.hash = hash 164 | 165 | targets = append(targets, target) 166 | } 167 | return targets 168 | } 169 | 170 | func serviceTUID(svc *apiv1.Service, port apiv1.ServicePort) string { 171 | return fmt.Sprintf("%s_%s_%s_%s", 172 | svc.Namespace, 173 | svc.Name, 174 | strings.ToLower(string(port.Protocol)), 175 | strconv.FormatInt(int64(port.Port), 10), 176 | ) 177 | } 178 | 179 | func serviceSourceFromNsName(namespace, name string) string { 180 | return "k8s/service/" + namespace + "/" + name 181 | } 182 | 183 | func serviceSource(svc *apiv1.Service) string { 184 | return serviceSourceFromNsName(svc.Namespace, svc.Name) 185 | } 186 | 187 | func toService(o interface{}) (*apiv1.Service, error) { 188 | svc, ok := o.(*apiv1.Service) 189 | if !ok { 190 | return nil, fmt.Errorf("received unexpected object type: %T", o) 191 | } 192 | return svc, nil 193 | } 194 | -------------------------------------------------------------------------------- /manager/config/provider/kubernetes/provider.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/netdata/sd/manager/config" 10 | "github.com/netdata/sd/pkg/k8s" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/rs/zerolog" 14 | "gopkg.in/yaml.v2" 15 | apiv1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/runtime" 18 | "k8s.io/apimachinery/pkg/watch" 19 | "k8s.io/client-go/kubernetes" 20 | "k8s.io/client-go/tools/cache" 21 | "k8s.io/client-go/util/workqueue" 22 | ) 23 | 24 | type Config struct { 25 | Namespace string 26 | ConfigMap string 27 | Key string 28 | } 29 | 30 | func validateConfig(cfg Config) error { 31 | if cfg.ConfigMap == "" { 32 | return errors.New("config map not set") 33 | } 34 | if cfg.Key == "" { 35 | return errors.New("config map key not set") 36 | } 37 | return nil 38 | } 39 | 40 | type Provider struct { 41 | namespace string 42 | cmap string 43 | cmapKey string 44 | client kubernetes.Interface 45 | inf cache.SharedInformer 46 | queue *workqueue.Type 47 | configCh chan []config.Config 48 | started chan struct{} 49 | log zerolog.Logger 50 | } 51 | 52 | func NewProvider(cfg Config) (*Provider, error) { 53 | if err := validateConfig(cfg); err != nil { 54 | return nil, fmt.Errorf("config validation: %v", err) 55 | } 56 | p, err := initProvider(cfg) 57 | if err != nil { 58 | return nil, fmt.Errorf("initialization: %v", err) 59 | } 60 | return p, nil 61 | } 62 | 63 | func initProvider(cfg Config) (*Provider, error) { 64 | client, err := k8s.Clientset() 65 | if err != nil { 66 | return nil, err 67 | } 68 | p := &Provider{ 69 | namespace: cfg.Namespace, 70 | cmap: cfg.ConfigMap, 71 | cmapKey: cfg.Key, 72 | client: client, 73 | configCh: make(chan []config.Config), 74 | started: make(chan struct{}), 75 | queue: workqueue.NewNamed("cmap"), 76 | log: log.New("k8s config provider"), 77 | } 78 | return p, nil 79 | } 80 | 81 | func (p Provider) String() string { 82 | return source(p.namespace, p.cmap, p.cmapKey) 83 | } 84 | 85 | func (p *Provider) Configs() chan []config.Config { 86 | return p.configCh 87 | } 88 | 89 | func (p *Provider) Run(ctx context.Context) { 90 | p.log.Info().Msg("instance is started") 91 | defer p.log.Info().Msg("instance is stopped") 92 | defer p.queue.ShutDown() 93 | 94 | p.inf = p.setupInformer(ctx) 95 | go p.inf.Run(ctx.Done()) 96 | 97 | if !cache.WaitForCacheSync(ctx.Done(), p.inf.HasSynced) { 98 | p.log.Error().Msg("failed to sync caches") 99 | return 100 | } 101 | 102 | go p.run(ctx) 103 | close(p.started) 104 | 105 | <-ctx.Done() 106 | } 107 | 108 | const resyncPeriod = 10 * time.Minute 109 | 110 | func (p *Provider) setupInformer(ctx context.Context) cache.SharedInformer { 111 | client := p.client.CoreV1().ConfigMaps(p.namespace) 112 | clw := &cache.ListWatch{ 113 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 114 | return client.List(ctx, options) 115 | }, 116 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 117 | return client.Watch(ctx, options) 118 | }, 119 | } 120 | inf := cache.NewSharedInformer(clw, &apiv1.ConfigMap{}, resyncPeriod) 121 | inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ 122 | AddFunc: func(obj interface{}) { p.enqueue(obj) }, 123 | UpdateFunc: func(_, obj interface{}) { p.enqueue(obj) }, 124 | DeleteFunc: func(obj interface{}) { p.enqueue(obj) }, 125 | }) 126 | return inf 127 | } 128 | func (p *Provider) enqueue(obj interface{}) { 129 | cmap, err := toConfigMap(obj) 130 | if err != nil || p.cmap != cmap.Name { 131 | return 132 | } 133 | if p.namespace != apiv1.NamespaceAll && p.namespace != cmap.Namespace { 134 | return 135 | } 136 | key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) 137 | if err != nil { 138 | return 139 | } 140 | p.queue.Add(key) 141 | } 142 | 143 | func (p *Provider) run(ctx context.Context) { 144 | for { 145 | item, shutdown := p.queue.Get() 146 | if shutdown { 147 | return 148 | } 149 | 150 | func() { 151 | defer p.queue.Done(item) 152 | 153 | key := item.(string) 154 | namespace, name, err := cache.SplitMetaNamespaceKey(key) 155 | if err != nil { 156 | return 157 | } 158 | 159 | item, exists, err := p.inf.GetStore().GetByKey(key) 160 | if err != nil { 161 | return 162 | } 163 | cfg := config.Config{Source: source(namespace, name, p.cmapKey)} 164 | 165 | if !exists { 166 | p.send(ctx, cfg) 167 | return 168 | } 169 | 170 | cmap, err := toConfigMap(item) 171 | if err != nil { 172 | return 173 | } 174 | 175 | data, ok := cmap.Data[p.cmapKey] 176 | if !ok { 177 | p.log.Debug().Msgf("cmap '%s/%s' has no '%s' key", cmap.Namespace, cmap.Name, p.cmapKey) 178 | p.send(ctx, cfg) 179 | return 180 | } 181 | 182 | if err := yaml.Unmarshal([]byte(data), &cfg.Pipeline); err != nil { 183 | p.log.Error().Err(err).Msgf("unable to decode '%s/%s' key '%s'", 184 | cmap.Namespace, cmap.Name, p.cmapKey) 185 | return 186 | } 187 | p.send(ctx, cfg) 188 | }() 189 | } 190 | } 191 | 192 | func (p *Provider) send(ctx context.Context, cfg config.Config) { 193 | select { 194 | case <-ctx.Done(): 195 | case p.configCh <- []config.Config{cfg}: 196 | } 197 | } 198 | 199 | func source(ns, name, key string) string { 200 | return fmt.Sprintf("k8s/cmap/%s/%s:%s", ns, name, key) 201 | } 202 | 203 | func toConfigMap(item interface{}) (*apiv1.ConfigMap, error) { 204 | cmap, ok := item.(*apiv1.ConfigMap) 205 | if !ok { 206 | return nil, fmt.Errorf("received unexpected object type: %T", item) 207 | } 208 | return cmap, nil 209 | } 210 | -------------------------------------------------------------------------------- /manager/config/provider/file/provider.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/netdata/sd/manager/config" 12 | "github.com/netdata/sd/pkg/log" 13 | 14 | "github.com/fsnotify/fsnotify" 15 | "github.com/rs/zerolog" 16 | "gopkg.in/yaml.v2" 17 | ) 18 | 19 | type ( 20 | Provider struct { 21 | paths []string 22 | watcher *fsnotify.Watcher 23 | cache cache 24 | refreshEvery time.Duration 25 | configCh chan []config.Config 26 | log zerolog.Logger 27 | } 28 | cache map[string]time.Time 29 | ) 30 | 31 | func NewProvider(paths []string) *Provider { 32 | return &Provider{ 33 | paths: paths, 34 | cache: make(cache), 35 | refreshEvery: time.Minute, 36 | configCh: make(chan []config.Config), 37 | log: log.New("file config provider"), 38 | } 39 | } 40 | 41 | func (c cache) lookup(path string) (time.Time, bool) { v, ok := c[path]; return v, ok } 42 | func (c cache) has(path string) bool { _, ok := c.lookup(path); return ok } 43 | func (c cache) remove(path string) { delete(c, path) } 44 | func (c cache) put(path string, modTime time.Time) { c[path] = modTime } 45 | 46 | func (p *Provider) Configs() chan []config.Config { 47 | return p.configCh 48 | } 49 | 50 | func (p *Provider) Run(ctx context.Context) { 51 | p.log.Info().Msg("instance is started") 52 | defer p.log.Info().Msg("instance is stopped") 53 | 54 | watcher, err := fsnotify.NewWatcher() 55 | if err != nil { 56 | p.log.Error().Err(err).Msg("failed to initialize fsnotify watcher") 57 | return 58 | } 59 | 60 | p.watcher = watcher 61 | defer p.stop() 62 | p.refresh(ctx) 63 | 64 | tk := time.NewTicker(p.refreshEvery) 65 | defer tk.Stop() 66 | 67 | for { 68 | select { 69 | case <-ctx.Done(): 70 | return 71 | case <-tk.C: 72 | p.refresh(ctx) 73 | case event := <-p.watcher.Events: 74 | if event.Name == "" || isChmod(event) || !p.fileMatches(event.Name) { 75 | break 76 | } 77 | if isCreate(event) && p.cache.has(event.Name) { 78 | // vim "backupcopy=no" case, already collected after Rename event. 79 | break 80 | } 81 | if isRename(event) { 82 | // It is common to modify files using vim. 83 | // When writing to a file a backup is made. "backupcopy" option tells how it's done. 84 | // Default is "no": rename the file and write a new one. 85 | // This is cheap attempt to not send empty group for the old file. 86 | time.Sleep(time.Millisecond * 100) 87 | } 88 | p.refresh(ctx) 89 | case err := <-p.watcher.Errors: 90 | if err != nil { 91 | p.log.Warn().Err(err).Msg("watch error event") 92 | } 93 | } 94 | } 95 | } 96 | 97 | func (p *Provider) refresh(ctx context.Context) { 98 | select { 99 | case <-ctx.Done(): 100 | return 101 | default: 102 | } 103 | 104 | var added, removed []config.Config 105 | seen := make(map[string]bool) 106 | 107 | for _, file := range p.listFiles() { 108 | fi, err := os.Lstat(file) 109 | if err != nil { 110 | p.log.Warn().Err(err).Msgf("failed to lstat '%s'", file) 111 | continue 112 | } 113 | if !fi.Mode().IsRegular() { 114 | continue 115 | } 116 | 117 | seen[file] = true 118 | if v, ok := p.cache.lookup(file); ok && v.Equal(fi.ModTime()) { 119 | continue 120 | } 121 | p.cache.put(file, fi.ModTime()) 122 | 123 | var cfg config.PipelineConfig 124 | switch err := load(&cfg, file); err { 125 | case nil: 126 | added = append(added, config.Config{Pipeline: &cfg, Source: file}) 127 | case io.EOF: 128 | removed = append(removed, config.Config{Source: file}) 129 | default: 130 | p.log.Warn().Err(err).Msgf("failed to load '%s'", file) 131 | } 132 | } 133 | 134 | for name := range p.cache { 135 | if seen[name] { 136 | continue 137 | } 138 | p.cache.remove(name) 139 | removed = append(removed, config.Config{Source: name}) 140 | } 141 | 142 | if updates := append(added, removed...); len(updates) > 0 { 143 | p.send(ctx, updates) 144 | } 145 | p.watchDirs() 146 | } 147 | 148 | func (p *Provider) fileMatches(file string) bool { 149 | for _, pattern := range p.paths { 150 | if ok, _ := filepath.Match(pattern, file); ok { 151 | return true 152 | } 153 | } 154 | return false 155 | } 156 | 157 | func (p *Provider) listFiles() (files []string) { 158 | for _, pattern := range p.paths { 159 | if matches, err := filepath.Glob(pattern); err == nil { 160 | files = append(files, matches...) 161 | } 162 | } 163 | return files 164 | } 165 | 166 | func (p *Provider) watchDirs() { 167 | for _, path := range p.paths { 168 | if idx := strings.LastIndex(path, "/"); idx > -1 { 169 | path = path[:idx] 170 | } else { 171 | path = "./" 172 | } 173 | if err := p.watcher.Add(path); err != nil { 174 | p.log.Warn().Err(err).Msgf("failed to start watching '%s'", path) 175 | } 176 | } 177 | } 178 | 179 | func (p *Provider) stop() { 180 | ctx, cancel := context.WithCancel(context.Background()) 181 | defer cancel() 182 | 183 | // closing the watcher deadlocks unless all events and errors are drained. 184 | go func() { 185 | for { 186 | select { 187 | case <-p.watcher.Errors: 188 | case <-p.watcher.Events: 189 | case <-ctx.Done(): 190 | return 191 | } 192 | } 193 | }() 194 | 195 | _ = p.watcher.Close() 196 | } 197 | 198 | func (p *Provider) send(ctx context.Context, cfgs []config.Config) { 199 | if len(cfgs) == 0 { 200 | return 201 | } 202 | select { 203 | case <-ctx.Done(): 204 | case p.configCh <- cfgs: 205 | } 206 | } 207 | 208 | func isChmod(event fsnotify.Event) bool { 209 | return event.Op^fsnotify.Chmod == 0 210 | } 211 | 212 | func isRename(event fsnotify.Event) bool { 213 | return event.Op&fsnotify.Rename == fsnotify.Rename 214 | } 215 | 216 | func isCreate(event fsnotify.Event) bool { 217 | return event.Op&fsnotify.Create == fsnotify.Create 218 | } 219 | 220 | func load(conf interface{}, path string) error { 221 | f, err := os.Open(path) 222 | if err != nil { 223 | return err 224 | } 225 | defer f.Close() 226 | 227 | return yaml.NewDecoder(f).Decode(conf) 228 | } 229 | -------------------------------------------------------------------------------- /pipeline/model/selector_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var reSrString = regexp.MustCompile(`^{[^{}]+}$`) 11 | 12 | func TestExactSelector_String(t *testing.T) { 13 | sr := exactSelector("selector") 14 | 15 | assert.True(t, reSrString.MatchString(sr.String())) 16 | } 17 | 18 | func TestNegSelector_String(t *testing.T) { 19 | srs := []Selector{ 20 | exactSelector("selector"), 21 | negSelector{exactSelector("selector")}, 22 | orSelector{ 23 | lhs: exactSelector("selector"), 24 | rhs: exactSelector("selector")}, 25 | orSelector{ 26 | lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 27 | rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 28 | }, 29 | andSelector{ 30 | lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 31 | rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 32 | }, 33 | } 34 | 35 | for i, sr := range srs { 36 | neg := negSelector{sr} 37 | assert.True(t, reSrString.MatchString(neg.String()), "selector num %d", i+1) 38 | } 39 | } 40 | 41 | func TestOrSelector_String(t *testing.T) { 42 | sr := orSelector{ 43 | lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 44 | rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 45 | } 46 | 47 | assert.True(t, reSrString.MatchString(sr.String())) 48 | } 49 | 50 | func TestAndSelector_String(t *testing.T) { 51 | sr := andSelector{ 52 | lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 53 | rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}}, 54 | } 55 | 56 | assert.True(t, reSrString.MatchString(sr.String())) 57 | } 58 | 59 | func TestExactSelector_Match(t *testing.T) { 60 | matchTests := struct { 61 | tags Tags 62 | srs []exactSelector 63 | }{ 64 | tags: Tags{"a": {}, "b": {}}, 65 | srs: []exactSelector{ 66 | "a", 67 | "b", 68 | }, 69 | } 70 | notMatchTests := struct { 71 | tags Tags 72 | srs []exactSelector 73 | }{ 74 | tags: Tags{"a": {}, "b": {}}, 75 | srs: []exactSelector{ 76 | "c", 77 | "d", 78 | }, 79 | } 80 | 81 | for i, sr := range matchTests.srs { 82 | assert.Truef(t, sr.Matches(matchTests.tags), "match selector num %d", i+1) 83 | } 84 | for i, sr := range notMatchTests.srs { 85 | assert.Falsef(t, sr.Matches(notMatchTests.tags), "not match selector num %d", i+1) 86 | } 87 | } 88 | 89 | func TestNegSelector_Match(t *testing.T) { 90 | matchTests := struct { 91 | tags Tags 92 | srs []negSelector 93 | }{ 94 | tags: Tags{"a": {}, "b": {}}, 95 | srs: []negSelector{ 96 | {exactSelector("c")}, 97 | {exactSelector("d")}, 98 | }, 99 | } 100 | notMatchTests := struct { 101 | tags Tags 102 | srs []negSelector 103 | }{ 104 | tags: Tags{"a": {}, "b": {}}, 105 | srs: []negSelector{ 106 | {exactSelector("a")}, 107 | {exactSelector("b")}, 108 | }, 109 | } 110 | 111 | for i, sr := range matchTests.srs { 112 | assert.Truef(t, sr.Matches(matchTests.tags), "match selector num %d", i+1) 113 | } 114 | for i, sr := range notMatchTests.srs { 115 | assert.Falsef(t, sr.Matches(notMatchTests.tags), "not match selector num %d", i+1) 116 | } 117 | } 118 | 119 | func TestOrSelector_Match(t *testing.T) { 120 | matchTests := struct { 121 | tags Tags 122 | srs []orSelector 123 | }{ 124 | tags: Tags{"a": {}, "b": {}}, 125 | srs: []orSelector{ 126 | { 127 | lhs: orSelector{lhs: exactSelector("c"), rhs: exactSelector("d")}, 128 | rhs: orSelector{lhs: exactSelector("e"), rhs: exactSelector("b")}, 129 | }, 130 | }, 131 | } 132 | notMatchTests := struct { 133 | tags Tags 134 | srs []orSelector 135 | }{ 136 | tags: Tags{"a": {}, "b": {}}, 137 | srs: []orSelector{ 138 | { 139 | lhs: orSelector{lhs: exactSelector("c"), rhs: exactSelector("d")}, 140 | rhs: orSelector{lhs: exactSelector("e"), rhs: exactSelector("f")}, 141 | }, 142 | }, 143 | } 144 | 145 | for i, sr := range matchTests.srs { 146 | assert.Truef(t, sr.Matches(matchTests.tags), "match selector num %d", i+1) 147 | } 148 | for i, sr := range notMatchTests.srs { 149 | assert.Falsef(t, sr.Matches(notMatchTests.tags), "not match selector num %d", i+1) 150 | } 151 | } 152 | 153 | func TestAndSelector_Match(t *testing.T) { 154 | matchTests := struct { 155 | tags Tags 156 | srs []andSelector 157 | }{ 158 | tags: Tags{"a": {}, "b": {}, "c": {}, "d": {}}, 159 | srs: []andSelector{ 160 | { 161 | lhs: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")}, 162 | rhs: andSelector{lhs: exactSelector("c"), rhs: exactSelector("d")}, 163 | }, 164 | }, 165 | } 166 | notMatchTests := struct { 167 | tags Tags 168 | srs []andSelector 169 | }{ 170 | tags: Tags{"a": {}, "b": {}, "c": {}, "d": {}}, 171 | srs: []andSelector{ 172 | { 173 | lhs: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")}, 174 | rhs: andSelector{lhs: exactSelector("c"), rhs: exactSelector("z")}, 175 | }, 176 | }, 177 | } 178 | 179 | for i, sr := range matchTests.srs { 180 | assert.Truef(t, sr.Matches(matchTests.tags), "match selector num %d", i+1) 181 | } 182 | for i, sr := range notMatchTests.srs { 183 | assert.Falsef(t, sr.Matches(notMatchTests.tags), "not match selector num %d", i+1) 184 | } 185 | } 186 | 187 | func TestParseSelector(t *testing.T) { 188 | tests := map[string]struct { 189 | wantSelector Selector 190 | wantErr bool 191 | }{ 192 | "": {wantSelector: trueSelector{}}, 193 | "a": {wantSelector: exactSelector("a")}, 194 | "Z": {wantSelector: exactSelector("Z")}, 195 | "a_b": {wantSelector: exactSelector("a_b")}, 196 | "a=b": {wantSelector: exactSelector("a=b")}, 197 | "!a": {wantSelector: negSelector{exactSelector("a")}}, 198 | "a b": {wantSelector: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")}}, 199 | "a|b": {wantSelector: orSelector{lhs: exactSelector("a"), rhs: exactSelector("b")}}, 200 | "*": {wantSelector: trueSelector{}}, 201 | "!*": {wantSelector: negSelector{trueSelector{}}}, 202 | "a b !c d|e f": { 203 | wantSelector: andSelector{ 204 | lhs: andSelector{ 205 | lhs: andSelector{ 206 | lhs: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")}, 207 | rhs: negSelector{exactSelector("c")}, 208 | }, 209 | rhs: orSelector{ 210 | lhs: exactSelector("d"), 211 | rhs: exactSelector("e"), 212 | }, 213 | }, 214 | rhs: exactSelector("f"), 215 | }, 216 | }, 217 | "!": {wantErr: true}, 218 | "a !": {wantErr: true}, 219 | "a!b": {wantErr: true}, 220 | "0a": {wantErr: true}, 221 | "a b c*": {wantErr: true}, 222 | "__": {wantErr: true}, 223 | "a|b|c*": {wantErr: true}, 224 | } 225 | 226 | for name, test := range tests { 227 | t.Run(name, func(t *testing.T) { 228 | sr, err := ParseSelector(name) 229 | 230 | if test.wantErr { 231 | assert.Nil(t, sr) 232 | assert.Error(t, err) 233 | } else { 234 | assert.NoError(t, err) 235 | assert.Equal(t, test.wantSelector, sr) 236 | } 237 | }) 238 | } 239 | } 240 | 241 | func TestMustParseSelector(t *testing.T) { 242 | tests := []string{ 243 | "!", 244 | "a !", 245 | "a!b", 246 | "0a", 247 | "a b c*", 248 | "__", 249 | "a|b|c*", 250 | } 251 | 252 | for _, test := range tests { 253 | f := func() { 254 | MustParseSelector(test) 255 | } 256 | assert.Panicsf(t, f, test) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /manager/config/provider/kubernetes/provider_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/netdata/sd/manager/config" 10 | "github.com/netdata/sd/pkg/k8s" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "gopkg.in/yaml.v2" 15 | apiv1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/runtime" 18 | "k8s.io/apimachinery/pkg/types" 19 | "k8s.io/client-go/kubernetes/fake" 20 | v1 "k8s.io/client-go/kubernetes/typed/core/v1" 21 | "k8s.io/client-go/util/workqueue" 22 | ) 23 | 24 | func TestMain(m *testing.M) { 25 | _ = os.Setenv(k8s.EnvFakeClient, "true") 26 | code := m.Run() 27 | _ = os.Unsetenv(k8s.EnvFakeClient) 28 | os.Exit(code) 29 | } 30 | 31 | func TestNewProvider(t *testing.T) { 32 | tests := map[string]struct { 33 | cfg Config 34 | expectErr bool 35 | }{ 36 | "valid config": { 37 | cfg: Config{ConfigMap: "cmap", Key: "key"}, 38 | }, 39 | "config map not set": { 40 | cfg: Config{Key: "key"}, 41 | expectErr: true, 42 | }, 43 | "config map key not set": { 44 | cfg: Config{ConfigMap: "cmap"}, 45 | expectErr: true, 46 | }, 47 | } 48 | 49 | for name, test := range tests { 50 | t.Run(name, func(t *testing.T) { 51 | p, err := NewProvider(test.cfg) 52 | 53 | if test.expectErr { 54 | assert.Error(t, err) 55 | } else { 56 | require.NoError(t, err) 57 | assert.NotNil(t, p) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestProvider_Configs(t *testing.T) { 64 | p, err := NewProvider(Config{ 65 | Namespace: "", 66 | ConfigMap: "cmap", 67 | Key: "key", 68 | }) 69 | require.NoError(t, err) 70 | assert.NotNil(t, p.Configs()) 71 | } 72 | 73 | const ( 74 | validKey = "valid.yml" 75 | invalidKey = "invalid.yml" 76 | ) 77 | 78 | func TestProvider_Run(t *testing.T) { 79 | tests := map[string]func() runSim{ 80 | "cmap exists before start": func() runSim { 81 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: validKey} 82 | cmap := prepareConfigMap("cmap") 83 | provider, _ := prepareProvider(cfg, cmap) 84 | 85 | expected := []config.Config{ 86 | cmapKeyToConfig(cmap, validKey), 87 | } 88 | 89 | sim := runSim{ 90 | provider: provider, 91 | expectedConfigs: expected, 92 | } 93 | return sim 94 | }, 95 | "cmap added after start": func() runSim { 96 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: validKey} 97 | cmap := prepareConfigMap("cmap") 98 | provider, client := prepareProvider(cfg) 99 | 100 | expected := []config.Config{ 101 | cmapKeyToConfig(cmap, validKey), 102 | } 103 | 104 | sim := runSim{ 105 | provider: provider, 106 | runAfterSync: func(ctx context.Context) { 107 | time.Sleep(time.Millisecond * 50) 108 | _, _ = client.Create(ctx, cmap, metav1.CreateOptions{}) 109 | }, 110 | expectedConfigs: expected, 111 | } 112 | return sim 113 | }, 114 | "cmap deleted after start": func() runSim { 115 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: validKey} 116 | cmap := prepareConfigMap("cmap") 117 | provider, client := prepareProvider(cfg, cmap) 118 | 119 | expected := []config.Config{ 120 | cmapKeyToConfig(cmap, validKey), 121 | {Source: source(cmap.Namespace, cmap.Name, validKey)}, 122 | } 123 | 124 | sim := runSim{ 125 | provider: provider, 126 | runAfterSync: func(ctx context.Context) { 127 | time.Sleep(time.Millisecond * 50) 128 | _ = client.Delete(ctx, cmap.Name, metav1.DeleteOptions{}) 129 | }, 130 | expectedConfigs: expected, 131 | } 132 | return sim 133 | }, 134 | "cmap updated after start": func() runSim { 135 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: validKey} 136 | cmap := prepareConfigMap("cmap") 137 | provider, client := prepareProvider(cfg, cmap) 138 | 139 | expected := []config.Config{ 140 | cmapKeyToConfig(cmap, validKey), 141 | cmapKeyToConfig(cmap, validKey), 142 | } 143 | 144 | sim := runSim{ 145 | provider: provider, 146 | runAfterSync: func(ctx context.Context) { 147 | time.Sleep(time.Millisecond * 50) 148 | cmapUpdated := cmap.DeepCopy() 149 | cmapUpdated.Data["key"] = "value" 150 | _, _ = client.Update(ctx, cmapUpdated, metav1.UpdateOptions{}) 151 | }, 152 | expectedConfigs: expected, 153 | } 154 | return sim 155 | }, 156 | "several cmaps exist before run": func() runSim { 157 | cfg := Config{Namespace: "default", ConfigMap: "cmap1", Key: validKey} 158 | cmap1 := prepareConfigMap("cmap1") 159 | cmap2 := prepareConfigMap("cmap2") 160 | cmap3 := prepareConfigMap("cmap3") 161 | provider, _ := prepareProvider(cfg, cmap1, cmap2, cmap3) 162 | 163 | expected := []config.Config{ 164 | cmapKeyToConfig(cmap1, validKey), 165 | } 166 | 167 | sim := runSim{ 168 | provider: provider, 169 | expectedConfigs: expected, 170 | } 171 | return sim 172 | }, 173 | "cmap exists, but has no needed key": func() runSim { 174 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: "missing"} 175 | cmap := prepareConfigMap("cmap") 176 | provider, _ := prepareProvider(cfg, cmap) 177 | 178 | expected := []config.Config{ 179 | cmapKeyToConfig(cmap, "missing"), 180 | } 181 | 182 | sim := runSim{ 183 | provider: provider, 184 | expectedConfigs: expected, 185 | } 186 | return sim 187 | }, 188 | "cmap exists, but key format is invalid": func() runSim { 189 | cfg := Config{Namespace: "default", ConfigMap: "cmap", Key: invalidKey} 190 | cmap := prepareConfigMap("cmap") 191 | provider, _ := prepareProvider(cfg, cmap) 192 | 193 | sim := runSim{ 194 | provider: provider, 195 | } 196 | return sim 197 | }, 198 | } 199 | 200 | for name, sim := range tests { 201 | t.Run(name, func(t *testing.T) { sim().run(t) }) 202 | } 203 | } 204 | 205 | func prepareConfigMap(name string) *apiv1.ConfigMap { 206 | return &apiv1.ConfigMap{ 207 | ObjectMeta: metav1.ObjectMeta{ 208 | Name: name, 209 | Namespace: "default", 210 | UID: types.UID("a03b8dc6-dc40-46dc-b571-5030e69d8167" + name), 211 | }, 212 | Data: map[string]string{ 213 | validKey: validConfig, 214 | invalidKey: "invalid", 215 | }, 216 | } 217 | } 218 | 219 | func prepareProvider(cfg Config, objects ...runtime.Object) (*Provider, v1.ConfigMapInterface) { 220 | client := fake.NewSimpleClientset(objects...) 221 | provider := &Provider{ 222 | namespace: cfg.Namespace, 223 | cmap: cfg.ConfigMap, 224 | cmapKey: cfg.Key, 225 | client: client, 226 | configCh: make(chan []config.Config), 227 | started: make(chan struct{}), 228 | queue: workqueue.NewNamed("cmap"), 229 | } 230 | return provider, client.CoreV1().ConfigMaps(cfg.Namespace) 231 | } 232 | 233 | func cmapKeyToConfig(cmap *apiv1.ConfigMap, key string) (cfg config.Config) { 234 | cfg.Source = source(cmap.Namespace, cmap.Name, key) 235 | if data, ok := cmap.Data[key]; ok { 236 | _ = yaml.Unmarshal([]byte(data), &cfg.Pipeline) 237 | } 238 | return cfg 239 | } 240 | 241 | const validConfig = ` 242 | name: k8s 243 | discovery: 244 | k8s: 245 | - tags: unknown 246 | role: pod 247 | tag: 248 | - selector: unknown 249 | match: 250 | - cond: '{{ true }}' 251 | tags: -unknown apache 252 | build: 253 | - selector: apache 254 | tags: file 255 | apply: 256 | - selector: apache 257 | tags: file 258 | template: | 259 | - module: apache 260 | name: apache 261 | export: 262 | file: 263 | - selector: file 264 | filename: "output.conf" 265 | ` 266 | -------------------------------------------------------------------------------- /pipeline/build/build_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | tests := map[string]buildSim{ 12 | "valid config": { 13 | cfg: Config{ 14 | { 15 | Selector: "unknown", 16 | Tags: "-unknown", 17 | Apply: []ApplyConfig{ 18 | {Selector: "wizard", Template: `class {{.Class}}`}, 19 | }, 20 | }, 21 | }, 22 | }, 23 | "empty config": { 24 | invalid: true, 25 | cfg: Config{}, 26 | }, 27 | "config rule->selector not set": { 28 | invalid: true, 29 | cfg: Config{ 30 | { 31 | Selector: "", 32 | Tags: "-unknown", 33 | Apply: []ApplyConfig{ 34 | {Selector: "wizard", Template: `class {{.Class}}`}, 35 | }, 36 | }, 37 | }, 38 | }, 39 | "config rule->selector bad syntax": { 40 | invalid: true, 41 | cfg: Config{ 42 | { 43 | Selector: "!", 44 | Tags: "-unknown", 45 | Apply: []ApplyConfig{ 46 | {Selector: "wizard", Template: `class {{.Class}}`}, 47 | }, 48 | }, 49 | }, 50 | }, 51 | "config rule->tags not set": { 52 | invalid: true, 53 | cfg: Config{ 54 | { 55 | Selector: "unknown", 56 | Tags: "", 57 | Apply: []ApplyConfig{ 58 | {Selector: "wizard", Template: `class {{.Class}}`}, 59 | }, 60 | }, 61 | }, 62 | }, 63 | "config rule->apply not set": { 64 | invalid: true, 65 | cfg: Config{ 66 | { 67 | Selector: "unknown", 68 | Tags: "-unknown", 69 | }, 70 | }, 71 | }, 72 | "config rule->apply->selector not set": { 73 | invalid: true, 74 | cfg: Config{ 75 | { 76 | Selector: "unknown", 77 | Tags: "-unknown", 78 | Apply: []ApplyConfig{ 79 | {Selector: "", Template: `class {{.Class}}`}, 80 | }, 81 | }, 82 | }, 83 | }, 84 | "config rule->apply->selector bad syntax": { 85 | invalid: true, 86 | cfg: Config{ 87 | { 88 | Selector: "unknown", 89 | Tags: "-unknown", 90 | Apply: []ApplyConfig{ 91 | {Selector: "!", Template: `class {{.Class}}`}, 92 | }, 93 | }, 94 | }, 95 | }, 96 | "config rule->apply->template not set": { 97 | invalid: true, 98 | cfg: Config{ 99 | { 100 | Selector: "unknown", 101 | Tags: "-unknown", 102 | Apply: []ApplyConfig{ 103 | {Selector: "wizard", Template: ""}, 104 | }, 105 | }, 106 | }, 107 | }, 108 | "config rule->apply->template missingkey (unknown func)": { 109 | invalid: true, 110 | cfg: Config{ 111 | { 112 | Selector: "unknown", 113 | Tags: "-unknown", 114 | Apply: []ApplyConfig{ 115 | {Selector: "wizard", Template: `class {{error .Class}}`}, 116 | }, 117 | }, 118 | }, 119 | }, 120 | } 121 | 122 | for name, sim := range tests { 123 | t.Run(name, func(t *testing.T) { sim.run(t) }) 124 | } 125 | } 126 | 127 | func TestManager_Build(t *testing.T) { 128 | tests := map[string]buildSim{ 129 | "4 rule service": { 130 | cfg: Config{ 131 | { 132 | Selector: "class", 133 | Tags: "built", 134 | Apply: []ApplyConfig{ 135 | {Selector: "*", Template: `Class: {{.Class}}`}, 136 | }, 137 | }, 138 | { 139 | Selector: "race", 140 | Tags: "built", 141 | Apply: []ApplyConfig{ 142 | {Selector: "*", Template: `Race: {{.Race}}`}, 143 | }, 144 | }, 145 | { 146 | Selector: "level", 147 | Tags: "built", 148 | Apply: []ApplyConfig{ 149 | {Selector: "*", Template: `Level: {{.Level}}`}, 150 | }, 151 | }, 152 | { 153 | Selector: "full", 154 | Tags: "built", 155 | Apply: []ApplyConfig{ 156 | {Selector: "*", Template: `Class: {{.Class}}, Race: {{.Race}}, Level: {{.Level}}`}, 157 | }, 158 | }, 159 | }, 160 | inputs: []buildSimInput{ 161 | { 162 | desc: "1st rule match", 163 | target: mockTarget{ 164 | tag: model.Tags{"class": {}}, 165 | Class: "fighter", Race: "orc", Level: 9001, 166 | }, 167 | expectedCfgs: []model.Config{ 168 | {Conf: "Class: fighter", Tags: model.Tags{"built": {}}}, 169 | }, 170 | }, 171 | { 172 | desc: "1st, 2nd rules match", 173 | target: mockTarget{ 174 | tag: model.Tags{"class": {}, "race": {}}, 175 | Class: "fighter", Race: "orc", Level: 9001, 176 | }, 177 | expectedCfgs: []model.Config{ 178 | {Conf: "Class: fighter", Tags: model.Tags{"built": {}}}, 179 | {Conf: "Race: orc", Tags: model.Tags{"built": {}}}, 180 | }, 181 | }, 182 | { 183 | desc: "1st, 2nd, 3rd rules match", 184 | target: mockTarget{ 185 | tag: model.Tags{"class": {}, "race": {}, "level": {}}, 186 | Class: "fighter", Race: "orc", Level: 9001, 187 | }, 188 | expectedCfgs: []model.Config{ 189 | {Conf: "Class: fighter", Tags: model.Tags{"built": {}}}, 190 | {Conf: "Race: orc", Tags: model.Tags{"built": {}}}, 191 | {Conf: "Level: 9001", Tags: model.Tags{"built": {}}}, 192 | }, 193 | }, 194 | { 195 | desc: "all rules match", 196 | target: mockTarget{ 197 | tag: model.Tags{"class": {}, "race": {}, "level": {}, "full": {}}, 198 | Class: "fighter", Race: "orc", Level: 9001, 199 | }, 200 | expectedCfgs: []model.Config{ 201 | {Conf: "Class: fighter", Tags: model.Tags{"built": {}}}, 202 | {Conf: "Race: orc", Tags: model.Tags{"built": {}}}, 203 | {Conf: "Level: 9001", Tags: model.Tags{"built": {}}}, 204 | {Conf: "Class: fighter, Race: orc, Level: 9001", Tags: model.Tags{"built": {}}}, 205 | }, 206 | }, 207 | }, 208 | }, 209 | } 210 | 211 | for name, sim := range tests { 212 | t.Run(name, func(t *testing.T) { sim.run(t) }) 213 | } 214 | } 215 | 216 | func TestRule_Build(t *testing.T) { 217 | tests := map[string]buildSim{ 218 | "simple rule": { 219 | cfg: Config{ 220 | { 221 | Selector: "build", 222 | Tags: "built", 223 | Apply: []ApplyConfig{ 224 | {Selector: "human", Template: `Class: {{.Class}}, Race: {{.Race}}, Level: {{.Level}}`}, 225 | {Selector: "missingkey", Template: `{{.Name}}`}, 226 | }, 227 | }, 228 | }, 229 | inputs: []buildSimInput{ 230 | { 231 | desc: "not match rule selector", 232 | target: mockTarget{ 233 | tag: model.Tags{"nothing": {}}, 234 | Class: "fighter", Race: "orc", Level: 9001, 235 | }, 236 | }, 237 | { 238 | desc: "not match rule match selector", 239 | target: mockTarget{ 240 | tag: model.Tags{"build": {}}, 241 | Class: "fighter", Race: "orc", Level: 9001, 242 | }, 243 | }, 244 | { 245 | desc: "match everything", 246 | target: mockTarget{ 247 | tag: model.Tags{"build": {}, "human": {}}, 248 | Class: "fighter", Race: "human", Level: 9001, 249 | }, 250 | expectedCfgs: []model.Config{ 251 | {Conf: "Class: fighter, Race: human, Level: 9001", Tags: model.Tags{"built": {}}}, 252 | }, 253 | }, 254 | { 255 | desc: "missingkey error", 256 | target: mockTarget{ 257 | tag: model.Tags{"build": {}, "missingkey": {}}, 258 | Class: "fighter", Race: "human", Level: 9001, 259 | }, 260 | }, 261 | }, 262 | }, 263 | } 264 | 265 | for name, sim := range tests { 266 | t.Run(name, func(t *testing.T) { sim.run(t) }) 267 | } 268 | } 269 | 270 | type mockTarget struct { 271 | tag model.Tags 272 | Class string 273 | Race string 274 | Level int 275 | } 276 | 277 | func (m mockTarget) Tags() model.Tags { return m.tag } 278 | func (mockTarget) Hash() uint64 { return 0 } 279 | func (mockTarget) TUID() string { return "" } 280 | 281 | func (m mockTarget) String() string { 282 | return fmt.Sprintf("Class: %s, Race: %s, Level: %d, Tags: %s", m.Class, m.Race, m.Level, m.Tags()) 283 | } 284 | -------------------------------------------------------------------------------- /pipeline/discovery/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/netdata/sd/pipeline/model" 12 | "github.com/netdata/sd/pkg/k8s" 13 | "github.com/netdata/sd/pkg/log" 14 | 15 | "github.com/ilyam8/hashstructure" 16 | "github.com/rs/zerolog" 17 | apiv1 "k8s.io/api/core/v1" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | "k8s.io/apimachinery/pkg/watch" 21 | "k8s.io/client-go/kubernetes" 22 | "k8s.io/client-go/tools/cache" 23 | "k8s.io/client-go/util/workqueue" 24 | ) 25 | 26 | type Role string 27 | 28 | const ( 29 | RolePod = "pod" 30 | RoleService = "service" 31 | ) 32 | 33 | func isRoleValid(role string) bool { return role == RolePod || role == RoleService } 34 | 35 | const ( 36 | envNodeName = "MY_NODE_NAME" 37 | ) 38 | 39 | type Config struct { 40 | APIServer string `yaml:"api_server"` 41 | Tags string `yaml:"tags"` 42 | Namespaces []string `yaml:"namespaces"` 43 | Role string `yaml:"role"` 44 | LocalMode bool `yaml:"local_mode"` 45 | Selector struct { 46 | Label string `yaml:"label"` 47 | Field string `yaml:"field"` 48 | } `yaml:"selector"` 49 | } 50 | 51 | func validateConfig(cfg Config) error { 52 | if !isRoleValid(cfg.Role) { 53 | return fmt.Errorf("invalid role '%s', valid roles: '%s', '%s'", cfg.Role, RolePod, RoleService) 54 | } 55 | if cfg.Tags == "" { 56 | return fmt.Errorf("no tags set for '%s' role", cfg.Role) 57 | } 58 | return nil 59 | } 60 | 61 | type ( 62 | discoverer interface { 63 | Discover(ctx context.Context, ch chan<- []model.Group) 64 | } 65 | Discovery struct { 66 | tags model.Tags 67 | namespaces []string 68 | role string 69 | selectorLabel string 70 | selectorField string 71 | client kubernetes.Interface 72 | discoverers []discoverer 73 | started chan struct{} 74 | log zerolog.Logger 75 | } 76 | ) 77 | 78 | func NewDiscovery(cfg Config) (*Discovery, error) { 79 | if err := validateConfig(cfg); err != nil { 80 | return nil, fmt.Errorf("k8s discovery config validation: %v", err) 81 | } 82 | 83 | d, err := initDiscovery(cfg) 84 | if err != nil { 85 | return nil, fmt.Errorf("k8s discovery initialization ('%s'): %v", cfg.Role, err) 86 | } 87 | return d, nil 88 | } 89 | 90 | func initDiscovery(cfg Config) (*Discovery, error) { 91 | tags, err := model.ParseTags(cfg.Tags) 92 | if err != nil { 93 | return nil, fmt.Errorf("parse config->tags: %v", err) 94 | } 95 | client, err := k8s.Clientset() 96 | if err != nil { 97 | return nil, fmt.Errorf("create clientset: %v", err) 98 | } 99 | namespaces := cfg.Namespaces 100 | if len(namespaces) == 0 { 101 | namespaces = []string{apiv1.NamespaceAll} 102 | } 103 | if cfg.LocalMode && cfg.Role == RolePod { 104 | if name := os.Getenv(envNodeName); name != "" { 105 | cfg.Selector.Field = joinSelectors(cfg.Selector.Field, "spec.nodeName="+name) 106 | } else { 107 | return nil, fmt.Errorf("local_mode is enabled, but env '%s' not set", envNodeName) 108 | } 109 | } 110 | 111 | d := &Discovery{ 112 | tags: tags, 113 | namespaces: namespaces, 114 | role: cfg.Role, 115 | selectorLabel: cfg.Selector.Label, 116 | selectorField: cfg.Selector.Field, 117 | client: client, 118 | discoverers: make([]discoverer, 0, len(namespaces)), 119 | started: make(chan struct{}), 120 | log: log.New("k8s discovery manager"), 121 | } 122 | return d, nil 123 | } 124 | 125 | func (d *Discovery) String() string { 126 | return "k8s discovery manager" 127 | } 128 | 129 | const resyncPeriod = 10 * time.Minute 130 | 131 | func (d *Discovery) Discover(ctx context.Context, in chan<- []model.Group) { 132 | for _, namespace := range d.namespaces { 133 | var dd discoverer 134 | switch d.role { 135 | case RolePod: 136 | dd = d.setupPodDiscoverer(ctx, namespace) 137 | case RoleService: 138 | dd = d.setupServiceDiscoverer(ctx, namespace) 139 | default: 140 | panic(fmt.Sprintf("unknown k8 discovery role: '%s'", d.role)) 141 | } 142 | d.discoverers = append(d.discoverers, dd) 143 | } 144 | if len(d.discoverers) == 0 { 145 | panic("k8s cant run discovery: zero discoverers") 146 | } 147 | 148 | d.log.Info().Msgf("registered: %v", d.discoverers) 149 | 150 | var wg sync.WaitGroup 151 | updates := make(chan []model.Group) 152 | 153 | for _, dd := range d.discoverers { 154 | wg.Add(1) 155 | go func(dd discoverer) { defer wg.Done(); dd.Discover(ctx, updates) }(dd) 156 | } 157 | 158 | wg.Add(1) 159 | go func() { defer wg.Done(); d.run(ctx, updates, in) }() 160 | 161 | close(d.started) 162 | 163 | wg.Wait() 164 | <-ctx.Done() 165 | } 166 | 167 | func (d *Discovery) run(ctx context.Context, updates chan []model.Group, in chan<- []model.Group) { 168 | for { 169 | select { 170 | case <-ctx.Done(): 171 | return 172 | case groups := <-updates: 173 | for _, group := range groups { 174 | for _, t := range group.Targets() { 175 | t.Tags().Merge(d.tags) 176 | } 177 | } 178 | select { 179 | case <-ctx.Done(): 180 | return 181 | case in <- groups: 182 | } 183 | } 184 | } 185 | } 186 | 187 | func (d *Discovery) setupPodDiscoverer(ctx context.Context, namespace string) *Pod { 188 | pod := d.client.CoreV1().Pods(namespace) 189 | podLW := &cache.ListWatch{ 190 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 191 | options.FieldSelector = d.selectorField 192 | options.LabelSelector = d.selectorLabel 193 | return pod.List(ctx, options) 194 | }, 195 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 196 | options.FieldSelector = d.selectorField 197 | options.LabelSelector = d.selectorLabel 198 | return pod.Watch(ctx, options) 199 | }, 200 | } 201 | 202 | cmap := d.client.CoreV1().ConfigMaps(namespace) 203 | cmapLW := &cache.ListWatch{ 204 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 205 | return cmap.List(ctx, options) 206 | }, 207 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 208 | return cmap.Watch(ctx, options) 209 | }, 210 | } 211 | 212 | secret := d.client.CoreV1().Secrets(namespace) 213 | secretLW := &cache.ListWatch{ 214 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 215 | return secret.List(ctx, options) 216 | }, 217 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 218 | return secret.Watch(ctx, options) 219 | }, 220 | } 221 | 222 | return NewPod( 223 | cache.NewSharedInformer(podLW, &apiv1.Pod{}, resyncPeriod), 224 | cache.NewSharedInformer(cmapLW, &apiv1.ConfigMap{}, resyncPeriod), 225 | cache.NewSharedInformer(secretLW, &apiv1.Secret{}, resyncPeriod), 226 | ) 227 | } 228 | 229 | func (d *Discovery) setupServiceDiscoverer(ctx context.Context, namespace string) *Service { 230 | svc := d.client.CoreV1().Services(namespace) 231 | clw := &cache.ListWatch{ 232 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 233 | options.FieldSelector = d.selectorField 234 | options.LabelSelector = d.selectorLabel 235 | return svc.List(ctx, options) 236 | }, 237 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 238 | options.FieldSelector = d.selectorField 239 | options.LabelSelector = d.selectorLabel 240 | return svc.Watch(ctx, options) 241 | }, 242 | } 243 | inf := cache.NewSharedInformer(clw, &apiv1.Service{}, resyncPeriod) 244 | return NewService(inf) 245 | } 246 | 247 | func enqueue(queue *workqueue.Type, obj interface{}) { 248 | key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) 249 | if err != nil { 250 | return 251 | } 252 | queue.Add(key) 253 | } 254 | 255 | func send(ctx context.Context, in chan<- []model.Group, group model.Group) { 256 | if group == nil { 257 | return 258 | } 259 | select { 260 | case <-ctx.Done(): 261 | case in <- []model.Group{group}: 262 | } 263 | } 264 | 265 | func calcHash(obj interface{}) (uint64, error) { 266 | return hashstructure.Hash(obj, nil) 267 | } 268 | 269 | func joinSelectors(srs ...string) string { 270 | var i int 271 | for _, v := range srs { 272 | if v != "" { 273 | srs[i] = v 274 | i++ 275 | } 276 | } 277 | return strings.Join(srs[:i], ",") 278 | } 279 | -------------------------------------------------------------------------------- /pipeline/tag/tag_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/netdata/sd/pipeline/model" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | tests := map[string]tagSim{ 12 | "valid config": { 13 | cfg: Config{ 14 | { 15 | Selector: "unknown", 16 | Tags: "-unknown", 17 | Match: []MatchConfig{ 18 | {Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 19 | }, 20 | }, 21 | }, 22 | }, 23 | "empty config": { 24 | cfg: Config{}, 25 | invalid: true, 26 | }, 27 | "config rule->selector not set": { 28 | invalid: true, 29 | cfg: Config{ 30 | { 31 | Selector: "", 32 | Tags: "-unknown", 33 | Match: []MatchConfig{ 34 | {Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 35 | }, 36 | }, 37 | }, 38 | }, 39 | "config rule->selector bad syntax": { 40 | invalid: true, 41 | cfg: Config{ 42 | { 43 | Selector: "!", 44 | Tags: "-unknown", 45 | Match: []MatchConfig{ 46 | {Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 47 | }, 48 | }, 49 | }, 50 | }, 51 | "config rule->tags not set": { 52 | invalid: true, 53 | cfg: Config{ 54 | { 55 | Selector: "unknown", 56 | Tags: "", 57 | Match: []MatchConfig{ 58 | {Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 59 | }, 60 | }, 61 | }, 62 | }, 63 | "config rule->match not set": { 64 | invalid: true, 65 | cfg: Config{ 66 | { 67 | Selector: "unknown", 68 | Tags: "-unknown", 69 | }, 70 | }, 71 | }, 72 | "config rule->match->selector bad syntax": { 73 | invalid: true, 74 | cfg: Config{ 75 | { 76 | Selector: "unknown", 77 | Tags: "-unknown", 78 | Match: []MatchConfig{ 79 | {Selector: "!", Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 80 | }, 81 | }, 82 | }, 83 | }, 84 | "config rule->match->tags not set": { 85 | invalid: true, 86 | cfg: Config{ 87 | { 88 | Selector: "unknown", 89 | Tags: "-unknown", 90 | Match: []MatchConfig{ 91 | {Tags: "", Expr: `{{eq .Class "wizard"}}`}, 92 | }, 93 | }, 94 | }, 95 | }, 96 | "config rule->match->expr not set": { 97 | invalid: true, 98 | cfg: Config{ 99 | { 100 | Selector: "unknown", 101 | Tags: "-unknown", 102 | Match: []MatchConfig{ 103 | {Tags: "wizard", Expr: ""}, 104 | }, 105 | }, 106 | }, 107 | }, 108 | "config rule->match->expr unknown func": { 109 | invalid: true, 110 | cfg: Config{ 111 | { 112 | Selector: "unknown", 113 | Tags: "-unknown", 114 | Match: []MatchConfig{ 115 | {Tags: "wizard", Expr: `{{error .Class "wizard"}}`}, 116 | }, 117 | }, 118 | }, 119 | }, 120 | } 121 | 122 | for name, sim := range tests { 123 | t.Run(name, func(t *testing.T) { sim.run(t) }) 124 | } 125 | } 126 | 127 | func TestManager_Tag(t *testing.T) { 128 | tests := map[string]tagSim{ 129 | "3 rule service": { 130 | cfg: Config{ 131 | { 132 | Selector: "unknown", 133 | Tags: "-unknown", 134 | Match: []MatchConfig{ 135 | {Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 136 | {Tags: "knight", Expr: `{{eq .Class "knight"}}`}, 137 | {Tags: "cleric", Expr: `{{eq .Class "cleric"}}`}, 138 | }, 139 | }, 140 | { 141 | Selector: "!unknown", 142 | Tags: "candidate", 143 | Match: []MatchConfig{ 144 | {Tags: "human", Expr: `{{eq .Race "human"}}`}, 145 | {Tags: "elf", Expr: `{{eq .Race "elf"}}`}, 146 | {Tags: "dwarf", Expr: `{{eq .Race "dwarf"}}`}, 147 | }, 148 | }, 149 | { 150 | Selector: "candidate", 151 | Tags: "-candidate", 152 | Match: []MatchConfig{ 153 | {Tags: "teamup", Expr: `{{gt .Level 9000}}`}, 154 | }, 155 | }, 156 | }, 157 | inputs: []tagSimInput{ 158 | { 159 | desc: "all rules fail", 160 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "fighter", Race: "orc", Level: 9001}, 161 | expectedTags: model.Tags{"unknown": {}}, 162 | }, 163 | { 164 | desc: "1st rule match", 165 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "knight", Race: "undead", Level: 9001}, 166 | expectedTags: model.Tags{"knight": {}}, 167 | }, 168 | { 169 | desc: "1st, 2nd rules match", 170 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "knight", Race: "human", Level: 8999}, 171 | expectedTags: model.Tags{"knight": {}, "human": {}, "candidate": {}}, 172 | }, 173 | { 174 | desc: "all rules match", 175 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "wizard", Race: "human", Level: 9001}, 176 | expectedTags: model.Tags{"wizard": {}, "human": {}, "teamup": {}}, 177 | }, 178 | { 179 | desc: "all rules match", 180 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "knight", Race: "dwarf", Level: 9001}, 181 | expectedTags: model.Tags{"knight": {}, "dwarf": {}, "teamup": {}}, 182 | }, 183 | { 184 | desc: "all rules match", 185 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "cleric", Race: "elf", Level: 9001}, 186 | expectedTags: model.Tags{"cleric": {}, "elf": {}, "teamup": {}}, 187 | }, 188 | }, 189 | }, 190 | } 191 | 192 | for name, sim := range tests { 193 | t.Run(name, func(t *testing.T) { sim.run(t) }) 194 | } 195 | } 196 | 197 | func TestRule_Tag(t *testing.T) { 198 | tests := map[string]tagSim{ 199 | "simple rule": { 200 | cfg: Config{ 201 | { 202 | Selector: "unknown", 203 | Tags: "-unknown", 204 | Match: []MatchConfig{ 205 | {Selector: "human", Tags: "wizard", Expr: `{{eq .Class "wizard"}}`}, 206 | {Tags: "missingkey", Expr: `{{eq .Name "yoda"}}`}, 207 | }, 208 | }, 209 | }, 210 | inputs: []tagSimInput{ 211 | { 212 | desc: "not match rule selector", 213 | target: mockTarget{Class: "fighter"}, 214 | expectedTags: nil, 215 | }, 216 | { 217 | desc: "not match rule match selector", 218 | target: mockTarget{tags: model.Tags{"unknown": {}}, Class: "fighter"}, 219 | expectedTags: model.Tags{"unknown": {}}, 220 | }, 221 | { 222 | desc: "not match rule match expression", 223 | target: mockTarget{tags: model.Tags{"unknown": {}, "human": {}}, Class: "fighter"}, 224 | expectedTags: model.Tags{"unknown": {}, "human": {}}, 225 | }, 226 | { 227 | desc: "match expression", 228 | target: mockTarget{tags: model.Tags{"unknown": {}, "human": {}}, Class: "wizard"}, 229 | expectedTags: model.Tags{"wizard": {}, "human": {}}, 230 | }, 231 | { 232 | desc: "match expression missingkey error", 233 | target: mockTarget{tags: model.Tags{"unknown": {}, "missingkey": {}}, Class: "knight"}, 234 | expectedTags: model.Tags{"unknown": {}, "missingkey": {}}, 235 | }, 236 | }, 237 | }, 238 | } 239 | 240 | for name, sim := range tests { 241 | t.Run(name, func(t *testing.T) { sim.run(t) }) 242 | } 243 | } 244 | 245 | func TestRule_Tag_UseCustomFunction(t *testing.T) { 246 | newSim := func(expr string) tagSim { 247 | return tagSim{ 248 | cfg: Config{ 249 | { 250 | Selector: "*", 251 | Tags: "-nothing", 252 | Match: []MatchConfig{ 253 | {Tags: "wizard", Expr: expr}, 254 | }, 255 | }, 256 | }, 257 | inputs: []tagSimInput{ 258 | { 259 | target: mockTarget{Class: "wizard", tags: model.Tags{"key": {}}}, 260 | expectedTags: model.Tags{"key": {}, "wizard": {}}, 261 | }, 262 | }, 263 | } 264 | } 265 | 266 | tests := map[string]tagSim{ 267 | "glob": newSim(`{{glob .Class "w*z*rd"}}`), 268 | "re": newSim(`{{re .Class "^w[iI]z.*d$"}}`), 269 | } 270 | 271 | for name, sim := range tests { 272 | t.Run(name, func(t *testing.T) { sim.run(t) }) 273 | } 274 | } 275 | 276 | type mockTarget struct { 277 | tags model.Tags 278 | Class string 279 | Race string 280 | Level int 281 | } 282 | 283 | func (m mockTarget) Tags() model.Tags { return m.tags } 284 | func (mockTarget) Hash() uint64 { return 0 } 285 | func (mockTarget) TUID() string { return "" } 286 | 287 | func (m mockTarget) String() string { 288 | return fmt.Sprintf("Class: %s, Race: %s, Level: %d, Tags: %s", m.Class, m.Race, m.Level, m.Tags()) 289 | } 290 | -------------------------------------------------------------------------------- /pipeline/discovery/kubernetes/pod.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/netdata/sd/pipeline/model" 11 | "github.com/netdata/sd/pkg/log" 12 | 13 | "github.com/rs/zerolog" 14 | apiv1 "k8s.io/api/core/v1" 15 | "k8s.io/client-go/tools/cache" 16 | "k8s.io/client-go/util/workqueue" 17 | ) 18 | 19 | type ( 20 | podGroup struct { 21 | targets []model.Target 22 | source string 23 | } 24 | PodTarget struct { 25 | model.Base `hash:"ignore"` 26 | hash uint64 27 | tuid string 28 | Address string 29 | 30 | Namespace string 31 | Name string 32 | Annotations map[string]interface{} 33 | Labels map[string]interface{} 34 | NodeName string 35 | PodIP string 36 | 37 | ControllerName string 38 | ControllerKind string 39 | 40 | ContName string 41 | Image string 42 | Env map[string]interface{} 43 | Port string 44 | PortName string 45 | PortProtocol string 46 | } 47 | ) 48 | 49 | func (pt PodTarget) Hash() uint64 { return pt.hash } 50 | func (pt PodTarget) TUID() string { return pt.tuid } 51 | 52 | func (pg podGroup) Source() string { return pg.source } 53 | func (pg podGroup) Targets() []model.Target { return pg.targets } 54 | 55 | type Pod struct { 56 | podInformer cache.SharedInformer 57 | cmapInformer cache.SharedInformer 58 | secretInformer cache.SharedInformer 59 | queue *workqueue.Type 60 | log zerolog.Logger 61 | } 62 | 63 | func NewPod(pod, cmap, secret cache.SharedInformer) *Pod { 64 | queue := workqueue.NewWithConfig(workqueue.QueueConfig{Name: "pod"}) 65 | pod.AddEventHandler(cache.ResourceEventHandlerFuncs{ 66 | AddFunc: func(obj interface{}) { enqueue(queue, obj) }, 67 | UpdateFunc: func(_, obj interface{}) { enqueue(queue, obj) }, 68 | DeleteFunc: func(obj interface{}) { enqueue(queue, obj) }, 69 | }) 70 | if cmap == nil || secret == nil { 71 | panic("nil cmap or secret informer") 72 | } 73 | 74 | return &Pod{ 75 | podInformer: pod, 76 | cmapInformer: cmap, 77 | secretInformer: secret, 78 | queue: queue, 79 | log: log.New("k8s pod discovery"), 80 | } 81 | } 82 | 83 | func (p Pod) String() string { 84 | return fmt.Sprintf("k8s %s discovery", RolePod) 85 | } 86 | 87 | func (p *Pod) Discover(ctx context.Context, in chan<- []model.Group) { 88 | p.log.Info().Msg("instance is started") 89 | defer p.log.Info().Msg("instance is stopped") 90 | defer p.queue.ShutDown() 91 | 92 | go p.podInformer.Run(ctx.Done()) 93 | go p.cmapInformer.Run(ctx.Done()) 94 | go p.secretInformer.Run(ctx.Done()) 95 | 96 | if !cache.WaitForCacheSync(ctx.Done(), 97 | p.podInformer.HasSynced, p.cmapInformer.HasSynced, p.secretInformer.HasSynced) { 98 | p.log.Error().Msg("failed to sync caches") 99 | return 100 | } 101 | 102 | go p.run(ctx, in) 103 | <-ctx.Done() 104 | } 105 | 106 | func (p *Pod) run(ctx context.Context, in chan<- []model.Group) { 107 | for { 108 | item, shutdown := p.queue.Get() 109 | if shutdown { 110 | return 111 | } 112 | 113 | func() { 114 | defer p.queue.Done(item) 115 | 116 | key := item.(string) 117 | namespace, name, err := cache.SplitMetaNamespaceKey(key) 118 | if err != nil { 119 | return 120 | } 121 | 122 | item, exists, err := p.podInformer.GetStore().GetByKey(key) 123 | if err != nil { 124 | return 125 | } 126 | 127 | if !exists { 128 | group := &podGroup{source: podSourceFromNsName(namespace, name)} 129 | send(ctx, in, group) 130 | return 131 | } 132 | 133 | pod, err := toPod(item) 134 | if err != nil { 135 | return 136 | } 137 | 138 | group := p.buildGroup(pod) 139 | send(ctx, in, group) 140 | }() 141 | } 142 | } 143 | 144 | func (p Pod) buildGroup(pod *apiv1.Pod) model.Group { 145 | if pod.Status.PodIP == "" || len(pod.Spec.Containers) == 0 { 146 | return &podGroup{ 147 | source: podSource(pod), 148 | } 149 | } 150 | return &podGroup{ 151 | source: podSource(pod), 152 | targets: p.buildTargets(pod), 153 | } 154 | } 155 | 156 | func (p Pod) buildTargets(pod *apiv1.Pod) (targets []model.Target) { 157 | var name, kind string 158 | for _, ref := range pod.OwnerReferences { 159 | if ref.Controller != nil && *ref.Controller { 160 | name = ref.Name 161 | kind = ref.Kind 162 | break 163 | } 164 | } 165 | 166 | for _, container := range pod.Spec.Containers { 167 | env := p.collectEnv(pod.Namespace, container) 168 | 169 | if len(container.Ports) == 0 { 170 | target := &PodTarget{ 171 | tuid: podTUID(pod, container), 172 | Address: pod.Status.PodIP, 173 | Namespace: pod.Namespace, 174 | Name: pod.Name, 175 | Annotations: toMapInterface(pod.Annotations), 176 | Labels: toMapInterface(pod.Labels), 177 | NodeName: pod.Spec.NodeName, 178 | PodIP: pod.Status.PodIP, 179 | ControllerName: name, 180 | ControllerKind: kind, 181 | ContName: container.Name, 182 | Image: container.Image, 183 | Env: toMapInterface(env), 184 | } 185 | hash, err := calcHash(target) 186 | if err != nil { 187 | continue 188 | } 189 | target.hash = hash 190 | 191 | targets = append(targets, target) 192 | } else { 193 | for _, port := range container.Ports { 194 | portNum := strconv.FormatUint(uint64(port.ContainerPort), 10) 195 | target := &PodTarget{ 196 | tuid: podTUIDWithPort(pod, container, port), 197 | Address: net.JoinHostPort(pod.Status.PodIP, portNum), 198 | Namespace: pod.Namespace, 199 | Name: pod.Name, 200 | Annotations: toMapInterface(pod.Annotations), 201 | Labels: toMapInterface(pod.Labels), 202 | NodeName: pod.Spec.NodeName, 203 | PodIP: pod.Status.PodIP, 204 | ControllerName: name, 205 | ControllerKind: kind, 206 | ContName: container.Name, 207 | Image: container.Image, 208 | Env: toMapInterface(env), 209 | Port: portNum, 210 | PortName: port.Name, 211 | PortProtocol: string(port.Protocol), 212 | } 213 | hash, err := calcHash(target) 214 | if err != nil { 215 | continue 216 | } 217 | target.hash = hash 218 | 219 | targets = append(targets, target) 220 | } 221 | } 222 | } 223 | return targets 224 | } 225 | 226 | func (p Pod) collectEnv(ns string, container apiv1.Container) map[string]string { 227 | vars := make(map[string]string) 228 | 229 | // When a key exists in multiple sources, 230 | // the value associated with the last source will take precedence. 231 | // Values defined by an Env with a duplicate key will take precedence. 232 | // 233 | // Order (https://github.com/kubernetes/kubectl/blob/master/pkg/describe/describe.go) 234 | // - envFrom: configMapRef, secretRef 235 | // - env: value || valueFrom: fieldRef, resourceFieldRef, secretRef, configMap 236 | 237 | for _, src := range container.EnvFrom { 238 | switch { 239 | case src.ConfigMapRef != nil: 240 | p.envFromConfigMap(vars, ns, src) 241 | case src.SecretRef != nil: 242 | p.envFromSecret(vars, ns, src) 243 | } 244 | } 245 | 246 | for _, env := range container.Env { 247 | if env.Name == "" || isVar(env.Name) { 248 | continue 249 | } 250 | switch { 251 | case env.Value != "": 252 | vars[env.Name] = env.Value 253 | case env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil: 254 | p.valueFromSecret(vars, ns, env) 255 | case env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil: 256 | p.valueFromConfigMap(vars, ns, env) 257 | } 258 | } 259 | if len(vars) == 0 { 260 | return nil 261 | } 262 | return vars 263 | } 264 | 265 | func (p Pod) valueFromConfigMap(vars map[string]string, ns string, env apiv1.EnvVar) { 266 | if env.ValueFrom.ConfigMapKeyRef.Name == "" || env.ValueFrom.ConfigMapKeyRef.Key == "" { 267 | return 268 | } 269 | 270 | sr := env.ValueFrom.ConfigMapKeyRef 271 | key := ns + "/" + sr.Name 272 | item, exist, err := p.cmapInformer.GetStore().GetByKey(key) 273 | if err != nil || !exist { 274 | return 275 | } 276 | cmap, err := toConfigMap(item) 277 | if err != nil { 278 | return 279 | } 280 | if v, ok := cmap.Data[sr.Key]; ok { 281 | vars[env.Name] = v 282 | } 283 | } 284 | 285 | func (p Pod) valueFromSecret(vars map[string]string, ns string, env apiv1.EnvVar) { 286 | if env.ValueFrom.SecretKeyRef.Name == "" || env.ValueFrom.SecretKeyRef.Key == "" { 287 | return 288 | } 289 | 290 | sr := env.ValueFrom.SecretKeyRef 291 | key := ns + "/" + sr.Name 292 | item, exist, err := p.secretInformer.GetStore().GetByKey(key) 293 | if err != nil || !exist { 294 | return 295 | } 296 | secret, err := toSecret(item) 297 | if err != nil { 298 | return 299 | } 300 | if v, ok := secret.Data[sr.Key]; ok { 301 | vars[env.Name] = string(v) 302 | } 303 | } 304 | 305 | func (p Pod) envFromConfigMap(vars map[string]string, ns string, src apiv1.EnvFromSource) { 306 | if src.ConfigMapRef.Name == "" { 307 | return 308 | } 309 | key := ns + "/" + src.ConfigMapRef.Name 310 | item, exist, err := p.cmapInformer.GetStore().GetByKey(key) 311 | if err != nil || !exist { 312 | return 313 | } 314 | cmap, err := toConfigMap(item) 315 | if err != nil { 316 | return 317 | } 318 | for k, v := range cmap.Data { 319 | vars[src.Prefix+k] = v 320 | } 321 | } 322 | 323 | func (p Pod) envFromSecret(vars map[string]string, ns string, src apiv1.EnvFromSource) { 324 | if src.SecretRef.Name == "" { 325 | return 326 | } 327 | key := ns + "/" + src.SecretRef.Name 328 | item, exist, err := p.secretInformer.GetStore().GetByKey(key) 329 | if err != nil || !exist { 330 | return 331 | } 332 | secret, err := toSecret(item) 333 | if err != nil { 334 | return 335 | } 336 | for k, v := range secret.Data { 337 | vars[src.Prefix+k] = string(v) 338 | } 339 | } 340 | 341 | func podTUID(pod *apiv1.Pod, container apiv1.Container) string { 342 | return fmt.Sprintf("%s_%s_%s", 343 | pod.Namespace, 344 | pod.Name, 345 | container.Name, 346 | ) 347 | } 348 | 349 | func podTUIDWithPort(pod *apiv1.Pod, container apiv1.Container, port apiv1.ContainerPort) string { 350 | return fmt.Sprintf("%s_%s_%s_%s_%s", 351 | pod.Namespace, 352 | pod.Name, 353 | container.Name, 354 | strings.ToLower(string(port.Protocol)), 355 | strconv.FormatUint(uint64(port.ContainerPort), 10), 356 | ) 357 | } 358 | 359 | func podSourceFromNsName(namespace, name string) string { 360 | return "k8s/pod/" + namespace + "/" + name 361 | } 362 | 363 | func podSource(pod *apiv1.Pod) string { 364 | return podSourceFromNsName(pod.Namespace, pod.Name) 365 | } 366 | 367 | func toPod(item interface{}) (*apiv1.Pod, error) { 368 | pod, ok := item.(*apiv1.Pod) 369 | if !ok { 370 | return nil, fmt.Errorf("received unexpected object type: %T", item) 371 | } 372 | return pod, nil 373 | } 374 | 375 | func toConfigMap(item interface{}) (*apiv1.ConfigMap, error) { 376 | cmap, ok := item.(*apiv1.ConfigMap) 377 | if !ok { 378 | return nil, fmt.Errorf("received unexpected object type: %T", item) 379 | } 380 | return cmap, nil 381 | } 382 | 383 | func toSecret(item interface{}) (*apiv1.Secret, error) { 384 | secret, ok := item.(*apiv1.Secret) 385 | if !ok { 386 | return nil, fmt.Errorf("received unexpected object type: %T", item) 387 | } 388 | return secret, nil 389 | } 390 | 391 | func isVar(name string) bool { 392 | // Variable references $(VAR_NAME) are expanded using the previous defined 393 | // environment variables in the container and any service environment 394 | // variables. 395 | return strings.IndexByte(name, '$') != -1 396 | } 397 | 398 | func toMapInterface(src map[string]string) map[string]interface{} { 399 | if src == nil { 400 | return nil 401 | } 402 | m := make(map[string]interface{}, len(src)) 403 | for k, v := range src { 404 | m[k] = v 405 | } 406 | return m 407 | } 408 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # Service discovery 11 | 12 | > [!WARNING] 13 | > 14 | > **Deprecation Notice**: This repository's service discovery functionality has been migrated to go.d.plugin in the main [Netdata repository](https://github.com/netdata/netdata). All future development, maintenance, and updates will continue there. 15 | 16 |
17 | Old readme 18 | 19 | Service discovery extracts all the potentially useful information from different sources, converts it to the 20 | configurations and exports them to the different destinations. 21 | 22 | ## Pipeline 23 | 24 | The service discovery pipeline has four jobs: 25 | 26 | | Job | Description | 27 | |:-----------------------:|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 28 | | [discovery](#Discovery) | Dynamically discovers monitoring targets by collecting events from kubernetes API server. It collects POD and SERVICE events. | 29 | | [tag](#Tag) | Dynamically add tags to discovered monitoring targets. Based on the POD and SERVICE fields and using patterns on them, one or more tags are attached to the monitoring targets. | 30 | | [build](#Build) | Dynamically creates data collection configurations for the monitored targets, using templates. | 31 | | [export](#Export) | Dynamically exports data collection configurations to allow netdata data collection plugins to use them. Data collection jobs in netdata are created and destroyed as needed. | 32 | 33 | Routing in a job and between jobs based on `tags` and `selector`. 34 | 35 | Pipeline configuration: 36 | 37 | ```yaml 38 | name: 39 | discovery: 40 | tag: 41 | build: 42 | export: 43 | ``` 44 | 45 | ## Tags and selectors 46 | 47 | Tag, build and export jobs have `selector`, the pipeline routes a target/config to the job only if its tags matches job 48 | selectors. 49 | 50 | Both tags and selector are just lists of words. 51 | 52 | A word must match the regex `^[a-zA-Z][a-zA-Z0-9=_.]*$`. 53 | 54 | Tags special cases: 55 | 56 | - `-word`: the word will be removed on tags merging. 57 | 58 | Selectors special cases: 59 | 60 | - `!word`: shouldn’t contain the word. 61 | - `word|word|word`: should contain any word. 62 | 63 | ## Discovery 64 | 65 | Discovery job dynamically discovers targets using one of the supported service-discovery mechanisms. 66 | 67 | Supported mechanisms: 68 | 69 | - [kubernetes](#Kubernetes) 70 | 71 | Discovery configuration: 72 | 73 | ```yaml 74 | k8s: 75 | - 76 | ``` 77 | 78 | ### Kubernetes 79 | 80 | Kubernetes discoverer retrieves targets from [Kubernetes'](https://kubernetes.io/) 81 | [REST API](https://kubernetes.io/docs/reference/). It always stays synchronized with the cluster state. 82 | 83 | Configuration options: 84 | 85 | ```yaml 86 | # Mandatory. Tags to add to all discovered targets. 87 | tags: 88 | 89 | # Mandatory. The Kubernetes role of entities that should be discovered. 90 | role: 91 | 92 | # Optional. Discover only targets that exist on the same node as service-discovery. 93 | # This option works only for 'pod' role and it requires MY_NODE_NAME env variable to be set. 94 | local_mode: 95 | 96 | # Optional. If omitted, all namespaces are used. 97 | namespaces: 98 | - 99 | ``` 100 | 101 | One of the following role types can be configured to discover targets: 102 | 103 | - `pod` 104 | - `service` 105 | 106 | #### Pod Role 107 | 108 | The pod role discovers all pods and exposes their containers as targets. For each declared port of a container, it 109 | generates single target. If there is no declared port it generates one target with empty `Port`, `PortName` 110 | and `PortProtocol` fields. 111 | 112 | Available pod target fields: 113 | 114 | | Name | Type | Value | 115 | |:-----------------|:------------------|:----------------------------------------------------------| 116 | | `TUID` | string | `Namespace_Name_ContName_PortProtocol_Port` | 117 | | `Address` | string | `PodIP:Port` | 118 | | `Namespace` | string | _pod.metadata.namespace_ | 119 | | `Name` | string | _pod.metadata.name_ | 120 | | `Annotations` | map[string]string | _pod.metadata.annotations_ | 121 | | `Labels` | map[string]string | _pod.metadata.labels_ | 122 | | `NodeName` | string | _pod.spec.nodeName_ | 123 | | `PodIP` | string | _pod.status.podIP_ | 124 | | `ControllerName` | string | _pod.OwnerReferences.Controller.Name_ | 125 | | `ControllerKind` | string | _pod.OwnerReferences.Controller.Kind_ | 126 | | `ContName` | string | _pod.spec.containers.name_ | 127 | | `Image` | string | _pod.spec.containers.image_ | 128 | | `Env` | map[string]string | _pod.spec.containers.env_ + _pod.spec.containers.envFrom_ | 129 | | `Port` | string | _pod.spec.containers.ports.containerPort_ | 130 | | `PortName` | string | _pod.spec.containers.ports.name_ | 131 | | `PortProtocol` | string | _pod.spec.containers.ports.protocol_ | 132 | 133 | #### Service Role 134 | 135 | The service role discovers a target for each service port for each service. 136 | 137 | Available service target fields: 138 | 139 | | Name | Type | Value | 140 | |:---------------|:------------------|:------------------------------------------| 141 | | `TUID` | string | `Namespace_Name_PortProtocol_Port` | 142 | | `Address` | string | `Name.Namespace.svc:Port` | 143 | | `Namespace` | string | _svc.metadata.namespace_ | 144 | | `Name` | string | _svc.metadata.name_ | 145 | | `Annotations` | map[string]string | _svc.metadata.annotations_ | 146 | | `Labels` | map[string]string | _svc.metadata.labels_ | 147 | | `Port` | string | _pod.spec.containers.ports.containerPort_ | 148 | | `PortName` | string | _pod.spec.containers.ports.name_ | 149 | | `PortProtocol` | string | _pod.spec.containers.ports.protocol_ | 150 | | `ClusterIP` | string | _svc.spec.clusterIP_ | 151 | | `ExternalName` | string | _svc.spec.externalName_ | 152 | | `Type` | string | _svc.spec.ports.type_ | 153 | 154 | ## Tag 155 | 156 | Tag job tags targets discovered by [discovery job](#Discovery). Its purpose is service identification. 157 | 158 | Configuration is a list of tag rules: 159 | 160 | ```yaml 161 | - 162 | ``` 163 | 164 | Tag rule configuration options: 165 | 166 | ```yaml 167 | # Mandatory. Routes targets to this tag rule with tags matching this selector. 168 | selector: 169 | 170 | # Mandatory. Tags to merge with the target tags if at least on of the match rules matches. 171 | tags: 172 | 173 | # Mandatory. Match rules, at least one should be defined. 174 | match: 175 | # Optional. Routes targets to this match rule with tags matching this selector. 176 | - selector: 177 | 178 | # Mandatory. Tags to merge with the target tags if this rule expression evaluates to true. 179 | tags: 180 | 181 | # Mandatory. Match expression. 182 | expr: 183 | ``` 184 | 185 | **Match expression evaluation result should be true or false**. 186 | 187 | Expression syntax is [go-template](https://golang.org/pkg/text/template/). 188 | 189 | ### Available functions 190 | 191 | - go-template [built-in functions](https://golang.org/pkg/text/template/#hdr-Functions). 192 | - [sprig functions](http://masterminds.github.io/sprig/). 193 | - custom functions. 194 | 195 | Custom functions: 196 | 197 | - `glob` reports whether arg1 matches the shell file name pattern. 198 | - `re` reports whether arg1 contains any match of the regular expression pattern. 199 | 200 | All these functions accepts two or more arguments, returning in effect: 201 | 202 | > func(arg1, arg2) || func(arg1, arg3) || func(arg1, arg4) ... 203 | 204 | ## Build 205 | 206 | Build job creates configurations from targets. 207 | 208 | Configuration is a list of build rules: 209 | 210 | ```yaml 211 | - 212 | ``` 213 | 214 | Build rule configuration options: 215 | 216 | ```yaml 217 | # Mandatory. Routes targets to this rule with tags matching this selector. 218 | selector: 219 | 220 | # Mandatory. Tags to add to all built by this rule configurations. 221 | tags: 222 | 223 | # Mandatory. Apply rules, at least one should be defined. 224 | apply: 225 | # Mandatory. Routes targets to this apply rule with tags matching this selector. 226 | - selector: 227 | 228 | # Optional. Tags to add to configurations built by this apply rule. 229 | tags: 230 | 231 | # Mandatory. Configuration template. 232 | template: