github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/pkg/scrape/discovery/file/file.go (about) 1 // Copyright 2015 The Prometheus Authors 2 // Licensed under the Apache License, Version 2.0 (the "License"); 3 // you may not use this file except in compliance with the License. 4 // You may obtain a copy of the License at 5 // 6 // http://www.apache.org/licenses/LICENSE-2.0 7 // 8 // Unless required by applicable law or agreed to in writing, software 9 // distributed under the License is distributed on an "AS IS" BASIS, 10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 package file 15 16 import ( 17 "context" 18 "encoding/json" 19 "fmt" 20 "io/ioutil" 21 "os" 22 "path/filepath" 23 "regexp" 24 "strings" 25 "sync" 26 "time" 27 28 "github.com/fsnotify/fsnotify" 29 "github.com/pkg/errors" 30 "github.com/prometheus/client_golang/prometheus" 31 "github.com/prometheus/common/model" 32 "github.com/pyroscope-io/pyroscope/pkg/scrape/config" 33 "github.com/pyroscope-io/pyroscope/pkg/scrape/discovery" 34 "github.com/pyroscope-io/pyroscope/pkg/scrape/discovery/targetgroup" 35 pmodel "github.com/pyroscope-io/pyroscope/pkg/scrape/model" 36 "github.com/sirupsen/logrus" 37 yaml "gopkg.in/yaml.v2" 38 ) 39 40 var ( 41 fileSDScanDuration = prometheus.NewSummary( 42 prometheus.SummaryOpts{ 43 Name: "pyroscope_sd_file_scan_duration_seconds", 44 Help: "The duration of the File-SD scan in seconds.", 45 Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, 46 }) 47 fileSDReadErrorsCount = prometheus.NewCounter( 48 prometheus.CounterOpts{ 49 Name: "pyroscope_sd_file_read_errors_total", 50 Help: "The number of File-SD read errors.", 51 }) 52 fileSDTimeStamp = NewTimestampCollector() 53 54 patFileSDName = regexp.MustCompile(`^[^*]*(\*[^/]*)?\.(json|yml|yaml|JSON|YML|YAML)$`) 55 56 // DefaultSDConfig is the default file SD configuration. 57 DefaultSDConfig = SDConfig{ 58 RefreshInterval: model.Duration(5 * time.Minute), 59 } 60 ) 61 62 func init() { 63 discovery.RegisterConfig(&SDConfig{}) 64 prometheus.MustRegister(fileSDScanDuration, fileSDReadErrorsCount, fileSDTimeStamp) 65 } 66 67 // SDConfig is the configuration for file based discovery. 68 type SDConfig struct { 69 Files []string `yaml:"files"` 70 RefreshInterval model.Duration `yaml:"refresh-interval,omitempty"` 71 } 72 73 // Name returns the name of the Config. 74 func (*SDConfig) Name() string { return "file" } 75 76 // NewDiscoverer returns a Discoverer for the Config. 77 func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { 78 return NewDiscovery(c, opts.Logger), nil 79 } 80 81 // SetDirectory joins any relative file paths with dir. 82 func (c *SDConfig) SetDirectory(dir string) { 83 for i, file := range c.Files { 84 c.Files[i] = config.JoinDir(dir, file) 85 } 86 } 87 88 // UnmarshalYAML implements the yaml.Unmarshaler interface. 89 func (c *SDConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { 90 *c = DefaultSDConfig 91 type plain SDConfig 92 err := unmarshal((*plain)(c)) 93 if err != nil { 94 return err 95 } 96 if len(c.Files) == 0 { 97 return errors.New("file service discovery config must contain at least one path name") 98 } 99 for _, name := range c.Files { 100 if !patFileSDName.MatchString(name) { 101 return errors.Errorf("path name %q is not valid for file discovery", name) 102 } 103 } 104 return nil 105 } 106 107 const fileSDFilepathLabel = model.MetaLabelPrefix + "filepath" 108 109 // TimestampCollector is a Custom Collector for Timestamps of the files. 110 type TimestampCollector struct { 111 Description *prometheus.Desc 112 discoverers map[*Discovery]struct{} 113 lock sync.RWMutex 114 } 115 116 // Describe method sends the description to the channel. 117 func (t *TimestampCollector) Describe(ch chan<- *prometheus.Desc) { 118 ch <- t.Description 119 } 120 121 // Collect creates constant metrics for each file with last modified time of the file. 122 func (t *TimestampCollector) Collect(ch chan<- prometheus.Metric) { 123 // New map to dedup filenames. 124 uniqueFiles := make(map[string]float64) 125 t.lock.RLock() 126 for fileSD := range t.discoverers { 127 fileSD.lock.RLock() 128 for filename, timestamp := range fileSD.timestamps { 129 uniqueFiles[filename] = timestamp 130 } 131 fileSD.lock.RUnlock() 132 } 133 t.lock.RUnlock() 134 for filename, timestamp := range uniqueFiles { 135 ch <- prometheus.MustNewConstMetric( 136 t.Description, 137 prometheus.GaugeValue, 138 timestamp, 139 filename, 140 ) 141 } 142 } 143 144 func (t *TimestampCollector) addDiscoverer(disc *Discovery) { 145 t.lock.Lock() 146 t.discoverers[disc] = struct{}{} 147 t.lock.Unlock() 148 } 149 150 func (t *TimestampCollector) removeDiscoverer(disc *Discovery) { 151 t.lock.Lock() 152 delete(t.discoverers, disc) 153 t.lock.Unlock() 154 } 155 156 // NewTimestampCollector creates a TimestampCollector. 157 func NewTimestampCollector() *TimestampCollector { 158 return &TimestampCollector{ 159 Description: prometheus.NewDesc( 160 "pyroscope_sd_file_mtime_seconds", 161 "Timestamp (mtime) of files read by FileSD. Timestamp is set at read time.", 162 []string{"filename"}, 163 nil, 164 ), 165 discoverers: make(map[*Discovery]struct{}), 166 } 167 } 168 169 // Discovery provides service discovery functionality based 170 // on files that contain target groups in JSON or YAML format. Refreshing 171 // happens using file watches and periodic refreshes. 172 type Discovery struct { 173 paths []string 174 watcher *fsnotify.Watcher 175 interval time.Duration 176 timestamps map[string]float64 177 lock sync.RWMutex 178 179 // lastRefresh stores which files were found during the last refresh 180 // and how many target groups they contained. 181 // This is used to detect deleted target groups. 182 lastRefresh map[string]int 183 logger logrus.FieldLogger 184 } 185 186 // NewDiscovery returns a new file discovery for the given paths. 187 func NewDiscovery(conf *SDConfig, logger logrus.FieldLogger) *Discovery { 188 disc := &Discovery{ 189 paths: conf.Files, 190 interval: time.Duration(conf.RefreshInterval), 191 timestamps: make(map[string]float64), 192 logger: logger, 193 } 194 fileSDTimeStamp.addDiscoverer(disc) 195 return disc 196 } 197 198 // listFiles returns a list of all files that match the configured patterns. 199 func (d *Discovery) listFiles() []string { 200 var paths []string 201 for _, p := range d.paths { 202 files, err := filepath.Glob(p) 203 if err != nil { 204 d.logger.WithError(err).WithField("glob", p).Error("Error expanding glob") 205 continue 206 } 207 paths = append(paths, files...) 208 } 209 return paths 210 } 211 212 // watchFiles sets watches on all full paths or directories that were configured for 213 // this file discovery. 214 func (d *Discovery) watchFiles() { 215 if d.watcher == nil { 216 panic("no watcher configured") 217 } 218 for _, p := range d.paths { 219 if idx := strings.LastIndex(p, "/"); idx > -1 { 220 p = p[:idx] 221 } else { 222 p = "./" 223 } 224 if err := d.watcher.Add(p); err != nil { 225 d.logger.WithError(err).WithField("path", p).Error("Error adding file watch") 226 } 227 } 228 } 229 230 // Run implements the Discoverer interface. 231 func (d *Discovery) Run(ctx context.Context, ch chan<- []*targetgroup.Group) { 232 watcher, err := fsnotify.NewWatcher() 233 if err != nil { 234 d.logger.WithError(err).Error("Error adding file watcher") 235 return 236 } 237 d.watcher = watcher 238 defer d.stop() 239 240 d.refresh(ctx, ch) 241 242 ticker := time.NewTicker(d.interval) 243 defer ticker.Stop() 244 245 for { 246 select { 247 case <-ctx.Done(): 248 return 249 250 case event := <-d.watcher.Events: 251 // fsnotify sometimes sends a bunch of events without name or operation. 252 // It's unclear what they are and why they are sent - filter them out. 253 if len(event.Name) == 0 { 254 break 255 } 256 // Everything but a chmod requires rereading. 257 if event.Op^fsnotify.Chmod == 0 { 258 break 259 } 260 // Changes to a file can spawn various sequences of events with 261 // different combinations of operations. For all practical purposes 262 // this is inaccurate. 263 // The most reliable solution is to reload everything if anything happens. 264 d.refresh(ctx, ch) 265 266 case <-ticker.C: 267 // Setting a new watch after an update might fail. Make sure we don't lose 268 // those files forever. 269 d.refresh(ctx, ch) 270 271 case err := <-d.watcher.Errors: 272 if err != nil { 273 d.logger.WithError(err).Error("Error watching file") 274 } 275 } 276 } 277 } 278 279 func (d *Discovery) writeTimestamp(filename string, timestamp float64) { 280 d.lock.Lock() 281 d.timestamps[filename] = timestamp 282 d.lock.Unlock() 283 } 284 285 func (d *Discovery) deleteTimestamp(filename string) { 286 d.lock.Lock() 287 delete(d.timestamps, filename) 288 d.lock.Unlock() 289 } 290 291 // stop shuts down the file watcher. 292 func (d *Discovery) stop() { 293 d.logger.WithField("paths", fmt.Sprintf("%v", d.paths)).Debug("Stopping file discovery...") 294 done := make(chan struct{}) 295 defer close(done) 296 297 fileSDTimeStamp.removeDiscoverer(d) 298 299 // Closing the watcher will deadlock unless all events and errors are drained. 300 go func() { 301 for { 302 select { 303 case <-d.watcher.Errors: 304 case <-d.watcher.Events: 305 // Drain all events and errors. 306 case <-done: 307 return 308 } 309 } 310 }() 311 if err := d.watcher.Close(); err != nil { 312 d.logger.WithError(err).WithField("paths", fmt.Sprintf("%v", d.paths)).Error("Error closing file watcher") 313 } 314 d.logger.Debug("File discovery stopped") 315 } 316 317 // refresh reads all files matching the discovery's patterns and sends the respective 318 // updated target groups through the channel. 319 func (d *Discovery) refresh(ctx context.Context, ch chan<- []*targetgroup.Group) { 320 t0 := time.Now() 321 defer func() { 322 fileSDScanDuration.Observe(time.Since(t0).Seconds()) 323 }() 324 ref := map[string]int{} 325 for _, p := range d.listFiles() { 326 tgroups, err := d.readFile(p) 327 if err != nil { 328 fileSDReadErrorsCount.Inc() 329 330 d.logger.WithField("path", p).WithError(err).Debug("Error reading file") 331 // Prevent deletion down below. 332 ref[p] = d.lastRefresh[p] 333 continue 334 } 335 select { 336 case ch <- tgroups: 337 case <-ctx.Done(): 338 return 339 } 340 341 ref[p] = len(tgroups) 342 } 343 // Send empty updates for sources that disappeared. 344 for f, n := range d.lastRefresh { 345 m, ok := ref[f] 346 if !ok || n > m { 347 d.logger.Debug("msg", "file_sd refresh found file that should be removed", "file", f) 348 d.deleteTimestamp(f) 349 for i := m; i < n; i++ { 350 select { 351 case ch <- []*targetgroup.Group{{Source: fileSource(f, i)}}: 352 case <-ctx.Done(): 353 return 354 } 355 } 356 } 357 } 358 d.lastRefresh = ref 359 360 d.watchFiles() 361 } 362 363 // readFile reads a JSON or YAML list of targets groups from the file, depending on its 364 // file extension. It returns full configuration target groups. 365 func (d *Discovery) readFile(filename string) ([]*targetgroup.Group, error) { 366 fd, err := os.Open(filename) 367 if err != nil { 368 return nil, err 369 } 370 defer fd.Close() 371 372 content, err := ioutil.ReadAll(fd) 373 if err != nil { 374 return nil, err 375 } 376 377 info, err := fd.Stat() 378 if err != nil { 379 return nil, err 380 } 381 382 var targetGroups []*targetgroup.Group 383 384 switch ext := filepath.Ext(filename); strings.ToLower(ext) { 385 case ".json": 386 if err := json.Unmarshal(content, &targetGroups); err != nil { 387 return nil, err 388 } 389 case ".yml", ".yaml": 390 if err := yaml.UnmarshalStrict(content, &targetGroups); err != nil { 391 return nil, err 392 } 393 default: 394 panic(errors.Errorf("discovery.File.readFile: unhandled file extension %q", ext)) 395 } 396 397 for i, tg := range targetGroups { 398 if tg == nil { 399 err = errors.New("nil target group item found") 400 return nil, err 401 } 402 403 tg.Source = fileSource(filename, i) 404 if tg.Labels == nil { 405 tg.Labels = pmodel.LabelSet{} 406 } 407 tg.Labels[fileSDFilepathLabel] = pmodel.LabelValue(filename) 408 } 409 410 d.writeTimestamp(filename, float64(info.ModTime().Unix())) 411 return targetGroups, nil 412 } 413 414 // fileSource returns a source ID for the i-th target group in the file. 415 func fileSource(filename string, i int) string { 416 return fmt.Sprintf("%s:%d", filename, i) 417 }