| 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 | } |