master
go 315 lines 8.28 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package ddsnmp
4
5 import (
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "runtime"
12 "slices"
13 "strings"
14 "sync"
15
16 "gopkg.in/yaml.v2"
17
18 "github.com/netdata/netdata/go/plugins/logger"
19 "github.com/netdata/netdata/go/plugins/pkg/executable"
20 "github.com/netdata/netdata/go/plugins/pkg/multipath"
21 "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
22 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
23 )
24
25 var log = logger.New().With("component", "snmp/ddsnmp")
26
27 var (
28 // Profile loading is intentionally global and cached to avoid reloading
29 // profiles for each SNMP job instance. This is a performance optimization
30 // as there can be many concurrent SNMP collection jobs.
31 ddProfiles []*Profile
32 loadOnce sync.Once
33 )
34
35 func loadProfiles() {
36 loadOnce.Do(func() {
37 profilesPaths := getProfilesDirs()
38 log.Infof("Loading SNMP profiles from %v", profilesPaths)
39 seen := make(map[string]bool)
40
41 for _, dir := range profilesPaths {
42 profiles, err := loadProfilesFromDir(dir, profilesPaths)
43 if err != nil {
44 if pluginconfig.IsStock(dir) || !errors.Is(err, os.ErrNotExist) {
45 log.Errorf("failed to load dd snmp profiles from '%s': %v", dir, err)
46 }
47 continue
48 }
49
50 if len(profiles) == 0 {
51 log.Infof("no dd snmp profiles found in '%s'", dir)
52 continue
53 }
54
55 log.Infof("found %d profiles in '%s'", len(profiles), dir)
56 profiles = slices.DeleteFunc(profiles, func(p *Profile) bool {
57 name := filepath.Base(p.SourceFile)
58 if seen[name] {
59 log.Infof("duplicate profile '%s' found in '%s', not adding it", name, dir)
60 return true
61 }
62 seen[name] = true
63 return false
64 })
65 ddProfiles = append(ddProfiles, profiles...)
66 }
67
68 if len(ddProfiles) == 0 {
69 log.Warningf("no dd snmp profiles found in any of the searched directories: %v", profilesPaths)
70 } else {
71 log.Infof("loaded %d dd snmp profiles total", len(ddProfiles))
72 }
73 })
74 }
75
76 // LoadProfileByName loads a single profile by filename (with or without extension).
77 // This supports loading abstract profiles (e.g., "_std-*.yaml") for tests and
78 // programmatic profile checks that intentionally bypass selector matching.
79 func LoadProfileByName(name string) (*Profile, error) {
80 paths := getProfilesDirs()
81
82 candidates := []string{name}
83 if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
84 candidates = []string{name + ".yaml", name + ".yml"}
85 }
86
87 var lastErr error
88 for _, cand := range candidates {
89 path, err := paths.Find(cand)
90 if err != nil {
91 lastErr = err
92 continue
93 }
94
95 profile, err := loadProfile(path, paths)
96 if err != nil {
97 return nil, err
98 }
99
100 if err := prepareLoadedProfile(profile); err != nil {
101 return nil, err
102 }
103
104 return profile, nil
105 }
106
107 if lastErr == nil {
108 lastErr = fmt.Errorf("profile '%s' not found", name)
109 }
110 return nil, lastErr
111 }
112
113 func loadProfilesFromDir(dirpath string, extendsPaths multipath.MultiPath) ([]*Profile, error) {
114 var profiles []*Profile
115
116 if err := filepath.WalkDir(dirpath, func(path string, d fs.DirEntry, err error) error {
117 if err != nil {
118 return err
119 }
120 if !strings.HasSuffix(d.Name(), ".yaml") && !strings.HasSuffix(d.Name(), ".yml") {
121 return nil
122 }
123 // Skip abstract profiles
124 if strings.HasPrefix(d.Name(), "_") {
125 return nil
126 }
127
128 profile, err := loadProfile(path, extendsPaths)
129 if err != nil {
130 log.Warningf("invalid profile '%s': %v", path, err)
131 return nil
132 }
133
134 if err := prepareLoadedProfile(profile); err != nil {
135 log.Warningf("invalid profile '%s': %v", path, err)
136 return nil
137 }
138
139 profiles = append(profiles, profile)
140 return nil
141 }); err != nil {
142 return nil, err
143 }
144
145 return profiles, nil
146 }
147
148 func loadProfile(filename string, extendsPaths multipath.MultiPath) (*Profile, error) {
149 return loadProfileWithExtendsMap(filename, extendsPaths, []string{})
150 }
151
152 func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath, stack []string) (*Profile, error) {
153 content, err := os.ReadFile(filename)
154 if err != nil {
155 return nil, err
156 }
157
158 var prof Profile
159 if err := yaml.Unmarshal(content, &prof.Definition); err != nil {
160 return nil, err
161 }
162
163 if prof.SourceFile == "" {
164 prof.SourceFile, _ = filepath.Abs(filename)
165 }
166 originID := profileOriginID(filename, extendsPaths)
167 setLicensingOriginProfileID(&prof, originID)
168 setBGPOriginProfileID(&prof, originID)
169
170 // Handle empty profiles - these are profiles where content has been deliberately removed,
171 // but the file itself is preserved. This ensures that when users update, their existing
172 // profile files are overwritten with empty content rather than being left with stale data.
173 if prof.Definition == nil {
174 prof.Definition = &ddprofiledefinition.ProfileDefinition{}
175 return &prof, nil
176 }
177
178 prof.extensionHierarchy = make([]*extensionInfo, 0, len(prof.Definition.Extends))
179 mergedBases := make([]*Profile, 0, len(prof.Definition.Extends))
180
181 for _, name := range prof.Definition.Extends {
182 if slices.Contains(stack, name) {
183 return nil, fmt.Errorf("circular extends detected: '%s' already included (in file: %s)", name, prof.SourceFile)
184 }
185
186 extPath, err := extendsPaths.Find(name)
187 if err != nil {
188 return nil, fmt.Errorf("cannot find extension '%s': %w", name, err)
189 }
190
191 mergedBase, err := loadProfileWithExtendsMap(extPath, extendsPaths, append(stack, name))
192 if err != nil {
193 return nil, err
194 }
195
196 extInfo := &extensionInfo{
197 name: name,
198 sourceFile: mergedBase.SourceFile,
199 extensions: mergedBase.extensionHierarchy,
200 }
201 prof.extensionHierarchy = append(prof.extensionHierarchy, extInfo)
202 mergedBases = append(mergedBases, mergedBase)
203 }
204
205 // Merge in reverse so later extends override earlier ones while the
206 // current profile still keeps the highest precedence.
207 for i := len(mergedBases) - 1; i >= 0; i-- {
208 if err := prof.merge(mergedBases[i]); err != nil {
209 return nil, err
210 }
211 }
212
213 return &prof, nil
214 }
215
216 func setLicensingOriginProfileID(prof *Profile, originID string) {
217 if prof == nil || prof.Definition == nil {
218 return
219 }
220 for i := range prof.Definition.Licensing {
221 if prof.Definition.Licensing[i].OriginProfileID == "" {
222 prof.Definition.Licensing[i].OriginProfileID = originID
223 }
224 }
225 }
226
227 func setBGPOriginProfileID(prof *Profile, originID string) {
228 if prof == nil || prof.Definition == nil {
229 return
230 }
231 for i := range prof.Definition.BGP {
232 if prof.Definition.BGP[i].OriginProfileID == "" {
233 prof.Definition.BGP[i].OriginProfileID = originID
234 }
235 }
236 }
237
238 func profileOriginID(filename string, paths multipath.MultiPath) string {
239 absFile, err := filepath.Abs(filename)
240 if err != nil {
241 absFile = filename
242 }
243 for _, dir := range paths {
244 absDir, err := filepath.Abs(dir)
245 if err != nil {
246 continue
247 }
248 rel, err := filepath.Rel(absDir, absFile)
249 if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
250 continue
251 }
252 return filepath.ToSlash(rel)
253 }
254 return filepath.ToSlash(filepath.Base(filename))
255 }
256
257 func prepareLoadedProfile(profile *Profile) error {
258 if err := profile.validate(); err != nil {
259 return err
260 }
261 if err := CompileTransforms(profile); err != nil {
262 return err
263 }
264 profile.removeConstantMetrics()
265 enrichProfile(profile)
266 handleCrossTableTagsWithoutMetrics(profile)
267 return nil
268 }
269
270 func getProfilesDirs() multipath.MultiPath {
271 if executable.Name == "test" {
272 return multipath.New(snmpProfilesDirFromThisFile())
273 }
274
275 if dir := filepath.Join(executable.Directory, "../config/go.d/snmp.profiles/default"); isDirExists(dir) {
276 return multipath.New(dir)
277 }
278
279 var dirs []string
280 for _, dir := range pluginconfig.CollectorsUserDirs() {
281 dirs = append(dirs, filepath.Join(dir, "snmp.profiles"))
282 }
283 dirs = append(dirs, filepath.Join(pluginconfig.CollectorsStockDir(), "snmp.profiles", "default"))
284
285 return multipath.New(dirs...)
286 }
287
288 func isDirExists(dir string) bool {
289 fi, err := os.Stat(dir)
290 if err != nil {
291 return !errors.Is(err, fs.ErrNotExist)
292 }
293 return fi.Mode().IsDir()
294 }
295
296 func snmpProfilesDirFromThisFile() string {
297 // runtime.Caller(0) returns the absolute path to THIS .go file at build time.
298 _, thisFile, _, ok := runtime.Caller(0)
299 if !ok {
300 return ""
301 }
302 base := filepath.Dir(thisFile)
303
304 candidates := []string{
305 filepath.Join(base, "..", "..", "..", "config", "go.d", "snmp.profiles", "default"),
306 }
307
308 for _, p := range candidates {
309 if isDirExists(p) {
310 abs, _ := filepath.Abs(p)
311 return abs
312 }
313 }
314 return ""
315 }