Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type SafeConfig struct {
C *Config
configReloadSuccess prometheus.Gauge
configReloadSeconds prometheus.Gauge
configChecksum string
}

func NewSafeConfig(reg prometheus.Registerer) *SafeConfig {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions config/reload.go
Original file line number Diff line number Diff line change
@@ -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
}
109 changes: 109 additions & 0 deletions config/reload_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
29 changes: 21 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"strconv"
"strings"
"syscall"
"time"

"github.com/alecthomas/kingpin/v2"
"github.com/prometheus/client_golang/prometheus"
Expand All @@ -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("<url>").String()
routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").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()
Comment thread
electron0zero marked this conversation as resolved.
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("<url>").String()
routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String()
toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9115")

moduleUnknownCounter = promauto.NewCounter(prometheus.CounterOpts{
Name: "blackbox_module_unknown_total",
Expand Down Expand Up @@ -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")
}

}
}()

Expand Down