diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d8b933..3571d750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Changes: * [FEATURE] * [ENHANCEMENT] * [BUGFIX] +* [FEATURE] Support config reload automatically - feature flag `config.enable-auto-reload`, `config.auto-reload-interval` +* [CHANGE] Config is not reloaded if the file content didn't change when using all reload methods ## 0.27.0 / 2025-06-26 diff --git a/README.md b/README.md index 8e0edc86..eb8a2f1b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,10 @@ Blackbox exporter is configured via a [configuration file](CONFIGURATION.md) and Blackbox exporter can reload its configuration file at runtime. If the new configuration is not well-formed, the changes will not be applied. A configuration reload is triggered by sending a `SIGHUP` to the Blackbox exporter process or by sending a HTTP POST request to the `/-/reload` endpoint. +Blackbox exporter also supports automatic configuration reloading. You can enable this feature using the `--config.enable-auto-reload` flag. +When enabled, the exporter will automatically check for changes to its configuration file at a specified interval in seconds. +The interval can be customized with the `--config.auto-reload-interval` flag, which is set to 30 (which is 30 seconds) by default. + To view all available command-line flags, run `./blackbox_exporter -h`. To specify which [configuration file](CONFIGURATION.md) to load, use the `--config.file` flag. diff --git a/config/config.go b/config/config.go index 7e14a7a8..9a75a393 100644 --- a/config/config.go +++ b/config/config.go @@ -87,6 +87,7 @@ type SafeConfig struct { C *Config configReloadSuccess prometheus.Gauge configReloadSeconds prometheus.Gauge + configChecksum string } func NewSafeConfig(reg prometheus.Registerer) *SafeConfig { @@ -115,6 +116,21 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er } }() + var currentConfigChecksum string + currentConfigChecksum, err = GenerateChecksum(confFile) + if err != nil { + return fmt.Errorf("failed to generate initial checksum for configuration file: %s", err) + } + if currentConfigChecksum == sc.configChecksum { + if logger != nil { + logger.Info("Configuration checksum check passed. No changes detected.") + } + return nil + } + if logger != nil { + logger.Info("Configuration file change detected, reloading the configuration.") + } + yamlReader, err := os.Open(confFile) if err != nil { return fmt.Errorf("error reading config file: %s", err) @@ -145,6 +161,7 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er sc.Lock() sc.C = c + sc.configChecksum = currentConfigChecksum sc.Unlock() return nil diff --git a/config/reload.go b/config/reload.go new file mode 100644 index 00000000..3d7de120 --- /dev/null +++ b/config/reload.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" +) + +func GenerateChecksum(yamlFilePath string) (string, error) { + hash := sha256.New() + yamlContent, err := os.ReadFile(yamlFilePath) + if err != nil { + return "", fmt.Errorf("error reading YAML file: %w", err) + } + _, err = hash.Write(yamlContent) + if err != nil { + return "", fmt.Errorf("error writing YAML file to hash: %w", err) + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/config/reload_test.go b/config/reload_test.go new file mode 100644 index 00000000..c70a0278 --- /dev/null +++ b/config/reload_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGenerateChecksum(t *testing.T) { + // Create a temporary file and establish the "original" state. + // All subsequent tests will measure against this state. + originalContent := []byte("modules:\n http_2xx:\n prober: http\n") + + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "config.yaml") + + if err := os.WriteFile(filePath, originalContent, 0644); err != nil { + t.Fatalf("Could not write initial file: %v", err) + } + + originalChecksum, err := GenerateChecksum(filePath) + if err != nil { + t.Fatalf("Could not generate initial checksum: %v", err) + } + if originalChecksum == "" { + t.Fatal(" Initial checksum should not be empty") + } + + t.Run("when content is appended", func(t *testing.T) { + testModification(t, filePath, originalContent, originalChecksum, []byte("modules:\n http_2xx:\n prober: http\n new_module:\n")) + }) + + t.Run("when content is completely replaced", func(t *testing.T) { + testModification(t, filePath, originalContent, originalChecksum, []byte("completely: different\n")) + }) + + t.Run("when content is cleared (file becomes empty)", func(t *testing.T) { + testModification(t, filePath, originalContent, originalChecksum, []byte("")) + }) + + // These tests do not fit the modify-restore pattern and are handled independently. + t.Run("should return an error for a non-existent file", func(t *testing.T) { + nonExistentPath := filepath.Join(tempDir, "this-file-does-not-exist.yaml") + + checksum, err := GenerateChecksum(nonExistentPath) + + if err == nil { + t.Fatal("Expected an error for a non-existent file, but got nil") + } + if checksum != "" { + t.Errorf("Checksum should be empty on error, but got: %s", checksum) + } + }) + + t.Run("should return an error when path is a directory", func(t *testing.T) { + checksum, err := GenerateChecksum(tempDir) + + if err == nil { + t.Fatal("Expected an error when the path is a directory, but got nil") + } + if checksum != "" { + t.Errorf("Checksum should be empty on error, but got: %s", checksum) + } + }) +} + +// testModification is a helper function that encapsulates the user's requested test logic: +// 1. Write new content. +// 2. Check that the checksum is different. +// 3. Write the original content back. +// 4. Check that the checksum is the same as the original. +func testModification(t *testing.T, filePath string, originalContent []byte, originalChecksum string, newContent []byte) { + t.Helper() + + if err := os.WriteFile(filePath, newContent, 0644); err != nil { + t.Fatalf("Failed to write new content to file: %v", err) + } + + modifiedChecksum, err := GenerateChecksum(filePath) + if err != nil { + t.Fatalf("Failed to generate checksum for modified file: %v", err) + } + if modifiedChecksum == originalChecksum { + t.Error("Checksum did not change after modifying file content") + } + if err := os.WriteFile(filePath, originalContent, 0644); err != nil { + t.Fatalf("Failed to restore original content to file: %v", err) + } + + restoredChecksum, err := GenerateChecksum(filePath) + if err != nil { + t.Fatalf("Failed to generate checksum for restored file: %v", err) + } + if restoredChecksum != originalChecksum { + t.Errorf("Checksum should be restored to original value, but it was not.\nOriginal: %s\nRestored: %s", originalChecksum, restoredChecksum) + } +} diff --git a/main.go b/main.go index 84cebfd9..c41a0ec5 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/client_golang/prometheus" @@ -47,14 +48,16 @@ import ( var ( sc = config.NewSafeConfig(prometheus.DefaultRegisterer) - configFile = kingpin.Flag("config.file", "Blackbox exporter configuration file.").Default("blackbox.yml").String() - timeoutOffset = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64() - configCheck = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool() - logLevelProber = kingpin.Flag("log.prober", "Log level for probe request logs. One of: [debug, info, warn, error]. Please see the section `Controlling log level for probe logs` in the project README for more information.").Default("info").String() - historyLimit = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint() - externalURL = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("").String() - routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("").String() - toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9115") + configFile = kingpin.Flag("config.file", "Blackbox exporter configuration file.").Default("blackbox.yml").String() + timeoutOffset = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64() + configCheck = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool() + logLevelProber = kingpin.Flag("log.prober", "Log level for probe request logs. One of: [debug, info, warn, error]. Please see the section `Controlling log level for probe logs` in the project README for more information.").Default("info").String() + enableAutoReload = kingpin.Flag("config.enable-auto-reload", "When enabled, Blackbox exporter will automatically reload its configuration file at a specified interval. The interval is defined by the `--config.auto-reload-interval` flag, which defaults to `30s`").Default().Bool() + autoReloadInterval = kingpin.Flag("config.auto-reload-interval", "Specifies the interval in seconds for checking and automatically reloading configuration file upon detecting changes.").Default("30").Uint() + historyLimit = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint() + externalURL = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("").String() + routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("").String() + toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9115") moduleUnknownCounter = promauto.NewCounter(prometheus.CounterOpts{ Name: "blackbox_module_unknown_total", @@ -156,7 +159,17 @@ func run() int { logger.Info("Reloaded config file") rc <- nil } + case <-time.Tick(time.Duration(*autoReloadInterval) * time.Second): + if !*enableAutoReload { + continue + } + if sc.ReloadConfig(*configFile, logger); err != nil { + logger.Error("Error reloading config", "err", err) + continue + } + logger.Info("Reloaded config file") } + } }()