feat: Support multiple configuration files (#389)

* Allow configuration to be distributed

* catch iteration errors when collecting config files

* rm unused func

* Fix suffix check for config loading

* test configuration loading

* GATUS_CONFIG_PATH can be a file or a directory now

* Add deprecation note

* Fix cs

Co-authored-by: TwiN <twin@linux.com>

* cs fixes

Co-authored-by: TwiN <twin@linux.com>

* cs fixes

Co-authored-by: TwiN <twin@linux.com>

* cs fixes

Co-authored-by: TwiN <twin@linux.com>

* cs + rm useless line

Co-authored-by: TwiN <twin@linux.com>

* Update config/config.go

Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
Henning Janßen 2023-01-07 05:46:19 +01:00 committed by GitHub
parent 844f417ea1
commit 8e14302765
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 179 additions and 55 deletions

View File

@ -149,7 +149,7 @@ If you want to create your own configuration, see [Docker](#docker) for informat
By default, the configuration file is expected to be at `config/config.yaml`. By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_FILE` environment variable. You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable. If `GATUS_CONFIG_PATH` points to a directory, all `*.yaml` and `*.yml` files inside this directory and its subdirectories are concatenated. The previously used environment variable `GATUS_CONFIG_FILE` is deprecated but still works.
Here's a simple example: Here's a simple example:
```yaml ```yaml

View File

@ -3,8 +3,10 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"os" "os"
"path/filepath"
"time" "time"
"github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting"
@ -40,6 +42,9 @@ var (
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid // ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
ErrInvalidSecurityConfig = errors.New("invalid security configuration") ErrInvalidSecurityConfig = errors.New("invalid security configuration")
// ErrEarlyReturn is returned to break out of a loop from a callback early
ErrEarlyReturn = errors.New("early escape")
) )
// Config is the main configuration structure // Config is the main configuration structure
@ -84,7 +89,7 @@ type Config struct {
// WARNING: This is in ALPHA and may change or be completely removed in the future // WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"` Remote *remote.Config `yaml:"remote,omitempty"`
filePath string // path to the file from which config was loaded from configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
@ -98,63 +103,80 @@ func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
return nil return nil
} }
// HasLoadedConfigurationFileBeenModified returns whether the file that the // HasLoadedConfigurationFileBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read // configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool { func (config Config) HasLoadedConfigurationFileBeenModified() bool {
if fileInfo, err := os.Stat(config.filePath); err == nil { lastMod := config.lastFileModTime.Unix()
if !fileInfo.ModTime().IsZero() { fileInfo, err := os.Stat(config.configPath)
return config.lastFileModTime.Unix() != fileInfo.ModTime().Unix() if err != nil {
}
}
return false return false
}
if fileInfo.IsDir() {
err = walkConfigDir(config.configPath, func (path string, d fs.DirEntry, err error) error {
if info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() {
return ErrEarlyReturn
}
return nil
})
return err == ErrEarlyReturn
}
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
} }
// UpdateLastFileModTime refreshes Config.lastFileModTime // UpdateLastFileModTime refreshes Config.lastFileModTime
func (config *Config) UpdateLastFileModTime() { func (config *Config) UpdateLastFileModTime() {
if fileInfo, err := os.Stat(config.filePath); err == nil { config.lastFileModTime = time.Now()
if !fileInfo.ModTime().IsZero() {
config.lastFileModTime = fileInfo.ModTime()
}
} else {
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
}
} }
// Load loads a custom configuration file // LoadConfiguration loads the full configuration composed from the main configuration file
// Note that the misconfiguration of some fields may lead to panics. This is on purpose. // and all composed configuration files
func Load(configFile string) (*Config, error) { func LoadConfiguration(configPath string) (*Config, error) {
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile) var composedContents []byte
cfg, err := readConfigurationFile(configFile) var fileInfo os.FileInfo
var usedConfigPath string = ""
for _, cpath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} {
if len(cpath) == 0 {
continue
}
var err error
fileInfo, err = os.Stat(cpath)
if err != nil { if err != nil {
if os.IsNotExist(err) { continue
}
usedConfigPath = configPath
break
}
if len(usedConfigPath) == 0 {
return nil, ErrConfigFileNotFound return nil, ErrConfigFileNotFound
} }
return nil, err
}
cfg.filePath = configFile
cfg.UpdateLastFileModTime()
return cfg, nil
}
// LoadDefaultConfiguration loads the default configuration file if fileInfo.IsDir() {
func LoadDefaultConfiguration() (*Config, error) { walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
cfg, err := Load(DefaultConfigurationFilePath) bytes, rerr := os.ReadFile(path)
if err != nil { if rerr == nil {
if err == ErrConfigFileNotFound { log.Printf("[config][Load] Reading configuration from configFile=%s", path)
return Load(DefaultFallbackConfigurationFilePath) composedContents = append(composedContents, bytes...)
}
return nil
})
} else {
bytes, serr := os.ReadFile(usedConfigPath)
if serr == nil {
log.Printf("[config][Load] Reading configuration from configFile=%s", configPath)
composedContents = bytes
} }
return nil, err
} }
return cfg, nil
}
func readConfigurationFile(fileName string) (config *Config, err error) { if len(composedContents) == 0 {
var bytes []byte return nil, ErrConfigFileNotFound
if bytes, err = os.ReadFile(fileName); err == nil {
// file exists, so we'll parse it and return it
return parseAndValidateConfigBytes(bytes)
} }
return config, err := parseAndValidateConfigBytes(composedContents)
config.configPath = usedConfigPath
config.UpdateLastFileModTime()
return config, err
} }
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters // parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
@ -332,3 +354,25 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
} }
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders) log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
} }
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
func walkConfigDir(path string, fn fs.WalkDirFunc) error {
if len(path) == 0 {
// If the user didn't provide a directory, we'll just use the default config file, so we can return nil now.
return nil
}
return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d == nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" {
return nil
}
return fn(path, d, err)
})
}

View File

@ -29,19 +29,40 @@ import (
) )
func TestLoadFileThatDoesNotExist(t *testing.T) { func TestLoadFileThatDoesNotExist(t *testing.T) {
_, err := Load("file-that-does-not-exist.yaml") _, err := LoadConfiguration("file-that-does-not-exist.yaml")
if err == nil { if err == nil {
t.Error("Should've returned an error, because the file specified doesn't exist") t.Error("Should've returned an error, because the file specified doesn't exist")
} }
} }
func TestLoadDefaultConfigurationFile(t *testing.T) { func TestLoadDefaultConfigurationFile(t *testing.T) {
_, err := LoadDefaultConfiguration() _, err := LoadConfiguration("")
if err == nil { if err == nil {
t.Error("Should've returned an error, because there's no configuration files at the default path nor the default fallback path") t.Error("Should've returned an error, because there's no configuration files at the default path nor the default fallback path")
} }
} }
func TestLoadConfigurationFile(t *testing.T) {
_, err := LoadConfiguration("../test-conf/config.yaml")
if nil != err {
t.Error("Should not have returned an error, because the configuration file exists at the provided path")
}
}
func TestLoadDistributedConfiguration(t *testing.T) {
_, err := LoadConfiguration("../test-conf/conf.d/")
if nil != err {
t.Error("Should not have returned an error, because configuration files exist at the provided path for distributed files")
}
}
func TestLoadCombinedConfiguration(t *testing.T) {
_, err := LoadConfiguration("../test-conf/empty-conf.d/")
if nil == err {
t.Error("Should have returned an error, because the configuration directory does not contain any configuration files")
}
}
func TestParseAndValidateConfigBytes(t *testing.T) { func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db" file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`

14
main.go
View File

@ -52,14 +52,14 @@ func save() {
} }
} }
func loadConfiguration() (cfg *config.Config, err error) { func loadConfiguration() (*config.Config, error) {
customConfigFile := os.Getenv("GATUS_CONFIG_FILE") var configPath = os.Getenv("GATUS_CONFIG_PATH")
if len(customConfigFile) > 0 { // Backwards compatibility
cfg, err = config.Load(customConfigFile) if len(configPath) == 0 {
} else { configPath = os.Getenv("GATUS_CONFIG_FILE")
cfg, err = config.LoadDefaultConfiguration()
} }
return
return config.LoadConfiguration(configPath)
} }
// initializeStorage initializes the storage provider // initializeStorage initializes the storage provider

View File

@ -0,0 +1,24 @@
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"

View File

@ -0,0 +1,29 @@
endpoints:
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"

6
test-conf/config.yaml Normal file
View File

@ -0,0 +1,6 @@
endpoints:
- name: example
url: https://example.org
interval: 30s
conditions:
- "[STATUS] == 200"

0
test-conf/empty-conf.d/.gitignore vendored Normal file
View File