Compare commits

...

15 Commits

Author SHA1 Message Date
TwiN
08aba6cd51 Minor updates 2021-11-04 21:40:05 -04:00
TwiN
d3805cd77a Fix #197; Fix #198: Deprecate storage.file in favor of storage.path and deprecate persistence with memory storage type 2021-11-04 21:33:13 -04:00
TwiN
dd70136e6c Omit empty hostname and errors field 2021-11-03 22:18:23 -04:00
TwiN
a94c480c22 Fix typo in comment 2021-11-03 22:17:58 -04:00
TwiN
10fd4ecd6b Minor fixes 2021-11-03 19:48:58 -04:00
TwiN
9287e2f9e2 Move store initialization to store package
This will allow importing storage.Config without importing every SQL drivers in the known universe
2021-10-28 19:35:46 -04:00
TwiN
257f859825 Rename getPagerDutyIntegrationKeyForGroup to getIntegrationKeyForGroup 2021-10-27 23:16:05 -04:00
TwiN
3a4ab62ddd #191: Handle memory issue caused by migration from Service to Endpoint 2021-10-24 21:20:01 -04:00
TwiN
a4e9d8e9b0 Revert "Add GATUS_DONT_EXPAND_ENV env var" 2021-10-24 18:34:39 -04:00
TwiN
3be6d04d29 Add GATUS_DONT_EXPAND_ENV env var 2021-10-24 16:20:24 -04:00
TwiN
b59ff6f89e Add ServiceAccount to Kubernetes example 2021-10-24 15:33:15 -04:00
TwiN
813fea93ee #167: Rename examples/minimal to .examples/docker-minimal 2021-10-24 15:27:25 -04:00
TwiN
8f50e44b45 #167: Rename examples/ to .examples/ 2021-10-24 15:20:39 -04:00
TwiN
fb2448c15a Omit fields that are not set 2021-10-24 15:03:41 -04:00
TwiN
db575aad13 Remove comments that no longer apply 2021-10-24 14:51:49 -04:00
47 changed files with 442 additions and 311 deletions

View File

@@ -1,4 +1,4 @@
examples .examples
Dockerfile Dockerfile
.github .github
.idea .idea

View File

@@ -1,6 +1,6 @@
storage: storage:
type: postgres type: postgres
file: "postgres://username:password@postgres:5432/gatus?sslmode=disable" path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
endpoints: endpoints:
- name: back-end - name: back-end

View File

@@ -1,6 +1,6 @@
storage: storage:
type: sqlite type: sqlite
file: /data/data.db path: /data/data.db
endpoints: endpoints:
- name: back-end - name: back-end

View File

@@ -9,14 +9,17 @@ data:
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
interval: 1m interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].status == UP"
- name: github - name: github
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: cat-fact - name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random" url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m interval: 5m
@@ -27,11 +30,18 @@ data:
- "[BODY].text == pat(*cat*)" - "[BODY].text == pat(*cat*)"
- "[STATUS] == pat(2*)" - "[STATUS] == pat(2*)"
- "[CONNECTED] == true" - "[CONNECTED] == true"
- name: example - name: example
url: https://example.com/ url: https://example.com/
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
--- ---
apiVersion: v1
kind: ServiceAccount
metadata:
name: gatus
namespace: kube-system
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -41,14 +51,16 @@ spec:
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
k8s-app: gatus app: gatus
template: template:
metadata: metadata:
labels: labels:
k8s-app: gatus app: gatus
name: gatus name: gatus
namespace: kube-system namespace: kube-system
spec: spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
containers: containers:
- image: twinproduction/gatus - image: twinproduction/gatus
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
@@ -84,4 +96,4 @@ spec:
protocol: TCP protocol: TCP
targetPort: 8080 targetPort: 8080
selector: selector:
k8s-app: gatus app: gatus

View File

@@ -17,7 +17,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: 1.16 go-version: 1.17
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Build binary to make sure it works - name: Build binary to make sure it works
@@ -25,9 +25,9 @@ jobs:
- name: Test - name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges. # We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that # As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable) # was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov - name: Codecov
uses: codecov/codecov-action@v1.5.2 uses: codecov/codecov-action@v2.1.0
with: with:
file: ./coverage.txt files: ./coverage.txt

View File

@@ -19,7 +19,7 @@ core applications: https://status.twin.sh/
<details> <details>
<summary><b>Quick start</b></summary> <summary><b>Quick start</b></summary>
``` ```console
docker run -p 8080:8080 --name gatus twinproduction/gatus docker run -p 8080:8080 --name gatus twinproduction/gatus
``` ```
For more details, see [Usage](#usage) For more details, see [Usage](#usage)
@@ -235,26 +235,31 @@ Here are some examples of conditions you can use:
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- | |:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
| `storage` | Storage configuration | `{}` | | `storage` | Storage configuration | `{}` |
| `storage.file` | Path to persist the data in. If the type is `memory`, data is persisted on interval. | `""` | | `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres` (ALPHA). | `"memory"` | | `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
- If `storage.type` is `memory` (default) and `storage.file` is set to a non-blank value. - If `storage.type` is `memory` (default):
Furthermore, the data is periodically persisted, but everything remains in memory. ```yaml
- If `storage.type` is `sqlite`, `storage.file` must not be blank: # Note that this is the default value, and you can omit the storage configuration altogether to achieve the same result.
# Because the data is stored in memory, the data will not survive a restart.
storage:
type: memory
```
- If `storage.type` is `sqlite`, `storage.path` must not be blank:
```yaml ```yaml
storage: storage:
type: sqlite type: sqlite
file: data.db path: data.db
``` ```
See [examples/docker-compose-sqlite-storage](examples/docker-compose-sqlite-storage) for an example. See [examples/docker-compose-sqlite-storage](.examples/docker-compose-sqlite-storage) for an example.
- If `storage.type` is `postgres`, `storage.file` must be the connection URL: - If `storage.type` is `postgres`, `storage.path` must be the connection URL:
```yaml ```yaml
storage: storage:
type: postgres type: postgres
file: "postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable" path: "postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable"
``` ```
See [examples/docker-compose-postgres-storage](examples/docker-compose-postgres-storage) for an example. See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres-storage) for an example.
### Client configuration ### Client configuration
@@ -798,29 +803,29 @@ maintenance:
## Deployment ## Deployment
Many examples can be found in the [examples](examples) folder, but this section will focus on the most popular ways of deploying Gatus. Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
### Docker ### Docker
To run Gatus locally with Docker: To run Gatus locally with Docker:
``` ```console
docker run -p 8080:8080 --name gatus twinproduction/gatus docker run -p 8080:8080 --name gatus twinproduction/gatus
``` ```
Other than using one of the examples provided in the `examples` folder, you can also try it out locally by Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
creating a configuration file, we'll call it `config.yaml` for this example, and running the following creating a configuration file, we'll call it `config.yaml` for this example, and running the following
command: command:
``` ```console
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
``` ```
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.: If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
``` ```console
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
``` ```
To build the image locally: To build the image locally:
``` ```console
docker build . -t twinproduction/gatus docker build . -t twinproduction/gatus
``` ```
@@ -845,7 +850,7 @@ Gatus can be deployed on Terraform by using the following module: [terraform-kub
## Running the tests ## Running the tests
``` ```console
go test ./... -mod vendor go test ./... -mod vendor
``` ```
@@ -937,8 +942,8 @@ endpoints:
- "[CONNECTED] == true" - "[CONNECTED] == true"
``` ```
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].insecure`, Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
`endpoints[].headers`, `endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints. `endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
**NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's **NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully something at the given address listening to the given port, and that a connection to that address was successfully
@@ -991,7 +996,7 @@ endpoints:
url: "starttls://smtp.gmail.com:587" url: "starttls://smtp.gmail.com:587"
interval: 30m interval: 30m
client: client:
timeout: 5s timeout: 5s
conditions: conditions:
- "[CONNECTED] == true" - "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h" - "[CERTIFICATE_EXPIRATION] > 48h"
@@ -1006,7 +1011,7 @@ endpoints:
url: "tls://ldap.example.com:636" url: "tls://ldap.example.com:636"
interval: 30m interval: 30m
client: client:
timeout: 5s timeout: 5s
conditions: conditions:
- "[CONNECTED] == true" - "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h" - "[CERTIFICATE_EXPIRATION] > 48h"

View File

@@ -71,15 +71,15 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
"source": "%s", "source": "%s",
"severity": "critical" "severity": "critical"
} }
}`, provider.getPagerDutyIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name), }`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name),
Headers: map[string]string{ Headers: map[string]string{
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
} }
// getPagerDutyIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group // getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getPagerDutyIntegrationKeyForGroup(group string) string { func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if group == override.Group { if group == override.Group {
@@ -87,10 +87,7 @@ func (provider *AlertProvider) getPagerDutyIntegrationKeyForGroup(group string)
} }
} }
} }
if provider.IntegrationKey != "" { return provider.IntegrationKey
return provider.IntegrationKey
}
return ""
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@@ -161,7 +161,7 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *tes
} }
} }
func TestAlertProvider_getPagerDutyIntegrationKey(t *testing.T) { func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
@@ -217,7 +217,7 @@ func TestAlertProvider_getPagerDutyIntegrationKey(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getPagerDutyIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput { if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output) t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
} }
}) })

View File

@@ -43,28 +43,28 @@ var (
// Config is the main configuration structure // Config is the main configuration structure
type Config struct { type Config struct {
// Debug Whether to enable debug logs // Debug Whether to enable debug logs
Debug bool `yaml:"debug"` Debug bool `yaml:"debug,omitempty"`
// Metrics Whether to expose metrics at /metrics // Metrics Whether to expose metrics at /metrics
Metrics bool `yaml:"metrics"` Metrics bool `yaml:"metrics,omitempty"`
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration // SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
// if the configuration file is updated while the application is running // if the configuration file is updated while the application is running
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"` SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"`
// DisableMonitoringLock Whether to disable the monitoring lock // DisableMonitoringLock Whether to disable the monitoring lock
// The monitoring lock is what prevents multiple endpoints from being processed at the same time. // The monitoring lock is what prevents multiple endpoints from being processed at the same time.
// Disabling this may lead to inaccurate response times // Disabling this may lead to inaccurate response times
DisableMonitoringLock bool `yaml:"disable-monitoring-lock"` DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
// Security Configuration for securing access to Gatus // Security Configuration for securing access to Gatus
Security *security.Config `yaml:"security"` Security *security.Config `yaml:"security,omitempty"`
// Alerting Configuration for alerting // Alerting Configuration for alerting
Alerting *alerting.Config `yaml:"alerting"` Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Endpoints List of endpoints to monitor // Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints"` Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Services List of endpoints to monitor // Services List of endpoints to monitor
// //
@@ -72,19 +72,19 @@ type Config struct {
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate // XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
// //
// Deprecated in favor of Endpoints // Deprecated in favor of Endpoints
Services []*core.Endpoint `yaml:"services"` Services []*core.Endpoint `yaml:"services,omitempty"`
// Storage is the configuration for how the data is stored // Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage"` Storage *storage.Config `yaml:"storage,omitempty"`
// Web is the web configuration for the application // Web is the web configuration for the application
Web *web.Config `yaml:"web"` Web *web.Config `yaml:"web,omitempty"`
// UI is the configuration for the UI // UI is the configuration for the UI
UI *ui.Config `yaml:"ui"` UI *ui.Config `yaml:"ui,omitempty"`
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent // Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance"` Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
filePath string // path to the file from which config was loaded from filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
@@ -149,12 +149,12 @@ func readConfigurationFile(fileName string) (config *Config, err error) {
return return
} }
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
// Expand environment variables // Expand environment variables
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes))) yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
// Parse configuration file // Parse configuration file
err = yaml.Unmarshal(yamlBytes, &config) if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
if err != nil {
return return
} }
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0 if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
@@ -167,8 +167,6 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 { if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig err = ErrNoEndpointInConfig
} else { } else {
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
// invalid configurations
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug) validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil { if err := validateSecurityConfig(config); err != nil {
return nil, err return nil, err
@@ -197,19 +195,10 @@ func validateStorageConfig(config *Config) error {
config.Storage = &storage.Config{ config.Storage = &storage.Config{
Type: storage.TypeMemory, Type: storage.TypeMemory,
} }
} } else {
err := storage.Initialize(config.Storage) if err := config.Storage.ValidateAndSetDefaults(); err != nil {
if err != nil { return err
return err }
}
// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration
var keys []string
for _, endpoint := range config.Endpoints {
keys = append(keys, endpoint.Key())
}
numberOfEndpointStatusesDeleted := storage.Get().DeleteAllEndpointStatusesNotInKeys(keys)
if numberOfEndpointStatusesDeleted > 0 {
log.Printf("[config][validateStorageConfig] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
} }
return nil return nil
} }

View File

@@ -20,6 +20,7 @@ import (
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
) )
func TestLoadFileThatDoesNotExist(t *testing.T) { func TestLoadFileThatDoesNotExist(t *testing.T) {
@@ -44,7 +45,8 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
}() }()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage: storage:
file: %s type: sqlite
path: %s
maintenance: maintenance:
enabled: true enabled: true
start: 00:00 start: 00:00
@@ -83,6 +85,9 @@ endpoints:
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("Config shouldn't have been nil")
} }
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
if config.UI == nil || config.UI.Title != "Test" { if config.UI == nil || config.UI.Title != "Test" {
t.Error("Expected Config.UI.Title to be Test") t.Error("Expected Config.UI.Title to be Test")
} }
@@ -1297,3 +1302,53 @@ endpoints:
t.Error("services should've been merged in endpoints") t.Error("services should've been merged in endpoints")
} }
} }
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
}
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageTypeMemoryAndFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: memory
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeMemory {
t.Error("expected storage to be set to memory, got", config.Storage)
}
}

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -40,7 +40,7 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
return return
} }
key := variables["key"] key := variables["key"]
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if err == common.ErrEndpointNotFound {
writer.WriteHeader(http.StatusNotFound) writer.WriteHeader(http.StatusNotFound)
@@ -79,7 +79,7 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
return return
} }
key := variables["key"] key := variables["key"]
averageResponseTime, err := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now()) averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if err == common.ErrEndpointNotFound {
writer.WriteHeader(http.StatusNotFound) writer.WriteHeader(http.StatusNotFound)

View File

@@ -9,12 +9,12 @@ import (
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v3/watchdog"
) )
func TestUptimeBadge(t *testing.T) { func TestUptimeBadge(t *testing.T) {
defer storage.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,

View File

@@ -7,7 +7,7 @@ import (
"sort" "sort"
"time" "time"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
@@ -42,7 +42,7 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest) http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
return return
} }
hourlyAverageResponseTime, err := storage.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now()) hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if err == common.ErrEndpointNotFound {
writer.WriteHeader(http.StatusNotFound) writer.WriteHeader(http.StatusNotFound)

View File

@@ -8,12 +8,12 @@ import (
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v3/watchdog"
) )
func TestResponseTimeChart(t *testing.T) { func TestResponseTimeChart(t *testing.T) {
defer storage.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,

View File

@@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging" "github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gocache" "github.com/TwiN/gocache"
@@ -44,7 +44,7 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
var err error var err error
buffer := &bytes.Buffer{} buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer) gzipWriter := gzip.NewWriter(buffer)
endpointStatuses, err := storage.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize)) endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil { if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error()) log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError) http.Error(writer, err.Error(), http.StatusInternalServerError)
@@ -76,7 +76,7 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
func EndpointStatus(writer http.ResponseWriter, r *http.Request) { func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r) page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r) vars := mux.Vars(r)
endpointStatus, err := storage.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) endpointStatus, err := store.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound) http.Error(writer, err.Error(), http.StatusNotFound)

View File

@@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v3/watchdog"
) )
@@ -84,7 +84,7 @@ var (
) )
func TestEndpointStatus(t *testing.T) { func TestEndpointStatus(t *testing.T) {
defer storage.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
@@ -153,12 +153,12 @@ func TestEndpointStatus(t *testing.T) {
} }
func TestEndpointStatuses(t *testing.T) { func TestEndpointStatuses(t *testing.T) {
defer storage.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
firstResult := &testSuccessfulResult firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult secondResult := &testUnsuccessfulResult
storage.Get().Insert(&testEndpoint, firstResult) store.Get().Insert(&testEndpoint, firstResult)
storage.Get().Insert(&testEndpoint, secondResult) store.Get().Insert(&testEndpoint, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
@@ -175,37 +175,37 @@ func TestEndpointStatuses(t *testing.T) {
Name: "no-pagination", Name: "no-pagination",
Path: "/api/v1/endpoints/statuses", Path: "/api/v1/endpoints/statuses",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ {
Name: "pagination-first-result", Name: "pagination-first-result",
Path: "/api/v1/endpoints/statuses?page=1&pageSize=1", Path: "/api/v1/endpoints/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ {
Name: "pagination-second-result", Name: "pagination-second-result",
Path: "/api/v1/endpoints/statuses?page=2&pageSize=1", Path: "/api/v1/endpoints/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ {
Name: "pagination-no-results", Name: "pagination-no-results",
Path: "/api/v1/endpoints/statuses?page=5&pageSize=20", Path: "/api/v1/endpoints/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
}, },
{ {
Name: "invalid-pagination-should-fall-back-to-default", Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID", Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ // XXX: Remove this in v4.0.0 { // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status", Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses", Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
} }

View File

@@ -8,12 +8,12 @@ import (
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v3/watchdog"
) )
func TestSinglePageApplication(t *testing.T) { func TestSinglePageApplication(t *testing.T) {
defer storage.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,

View File

@@ -80,7 +80,7 @@ const (
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25 maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
) )
// Condition is a condition that needs to be met in order for a Endpoint to be considered healthy. // Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
type Condition string type Condition string
// evaluate the Condition with the Result of the health check // evaluate the Condition with the Result of the health check
@@ -283,7 +283,7 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator) return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
} }
// XXX: make this configurable? i.e. show-resolved-conditions-on-failure // prettify returns a string representation of a condition with its parameters resolved between parentheses
func prettify(parameters []string, resolvedParameters []string, operator string) string { func prettify(parameters []string, resolvedParameters []string, operator string) string {
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself, // Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
// we'll return the resolvedParameters as-is. // we'll return the resolvedParameters as-is.

View File

@@ -79,19 +79,19 @@ type Endpoint struct {
Conditions []*Condition `yaml:"conditions"` Conditions []*Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure // Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts"` Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the endpoint's target // ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client"` ClientConfig *client.Config `yaml:"client,omitempty"`
// UIConfig is the configuration for the UI // UIConfig is the configuration for the UI
UIConfig *ui.Config `yaml:"ui"` UIConfig *ui.Config `yaml:"ui,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row // NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int NumberOfFailuresInARow int `yaml:"-"`
// NumberOfSuccessesInARow is the number of successful evaluations in a row // NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int NumberOfSuccessesInARow int `yaml:"-"`
} }
// IsEnabled returns whether the endpoint is enabled or not // IsEnabled returns whether the endpoint is enabled or not

View File

@@ -17,7 +17,7 @@ type EndpointStatus struct {
Results []*Result `json:"results"` Results []*Result `json:"results"`
// Events is a list of events // Events is a list of events
Events []*Event `json:"events"` Events []*Event `json:"events,omitempty"`
// Uptime information on the endpoint's uptime // Uptime information on the endpoint's uptime
// //

View File

@@ -13,7 +13,7 @@ type Result struct {
DNSRCode string `json:"-"` DNSRCode string `json:"-"`
// Hostname extracted from Endpoint.URL // Hostname extracted from Endpoint.URL
Hostname string `json:"hostname"` Hostname string `json:"hostname,omitempty"`
// IP resolved from the Endpoint URL // IP resolved from the Endpoint URL
IP string `json:"-"` IP string `json:"-"`
@@ -25,7 +25,7 @@ type Result struct {
Duration time.Duration `json:"duration"` Duration time.Duration `json:"duration"`
// Errors encountered during the evaluation of the Endpoint's health // Errors encountered during the evaluation of the Endpoint's health
Errors []string `json:"errors"` Errors []string `json:"errors,omitempty"`
// ConditionResults results of the Endpoint's conditions // ConditionResults results of the Endpoint's conditions
ConditionResults []*ConditionResult `json:"conditionResults"` ConditionResults []*ConditionResult `json:"conditionResults"`

27
main.go
View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/controller" "github.com/TwiN/gatus/v3/controller"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v3/watchdog"
) )
@@ -18,6 +18,7 @@ func main() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
initializeStorage(cfg)
start(cfg) start(cfg)
// Wait for termination signal // Wait for termination signal
signalChannel := make(chan os.Signal, 1) signalChannel := make(chan os.Signal, 1)
@@ -46,8 +47,7 @@ func stop() {
} }
func save() { func save() {
err := storage.Get().Save() if err := store.Get().Save(); err != nil {
if err != nil {
log.Println("Failed to save storage provider:", err.Error()) log.Println("Failed to save storage provider:", err.Error())
} }
} }
@@ -62,6 +62,27 @@ func loadConfiguration() (cfg *config.Config, err error) {
return return
} }
// initializeStorage initializes the storage provider
//
// Q: "TwiN, why are you putting this here? Wouldn't it make more sense to have this in the config?!"
// A: Yes. Yes it would make more sense to have it in the config package. But I don't want to import
// the massive SQL dependencies just because I want to import the config, so here we are.
func initializeStorage(cfg *config.Config) {
err := store.Initialize(cfg.Storage)
if err != nil {
panic(err)
}
// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration
var keys []string
for _, endpoint := range cfg.Endpoints {
keys = append(keys, endpoint.Key())
}
numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)
if numberOfEndpointStatusesDeleted > 0 {
log.Printf("[config][validateStorageConfig] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
}
}
func listenToConfigurationFileChanges(cfg *config.Config) { func listenToConfigurationFileChanges(cfg *config.Config) {
for { for {
time.Sleep(30 * time.Second) time.Sleep(30 * time.Second)

View File

@@ -1,14 +1,59 @@
package storage package storage
import (
"errors"
"log"
)
var (
ErrSQLStorageRequiresFile = errors.New("sql storage requires a non-empty file to be defined")
ErrMemoryStorageDoesNotSupportFile = errors.New("memory storage does not support persistence, use sqlite if you want persistence on file")
ErrCannotSetBothFileAndPath = errors.New("file has been deprecated in favor of path: you cannot set both of them")
)
// Config is the configuration for storage // Config is the configuration for storage
type Config struct { type Config struct {
// Path is the path used by the store to achieve persistence
// If blank, persistence is disabled.
// Note that not all Type support persistence
//
// XXX: Rename to path for v4.0.0
Path string `yaml:"path"`
// File is the path of the file to use for persistence // File is the path of the file to use for persistence
// If blank, persistence is disabled // If blank, persistence is disabled
// //
// XXX: Rename to path for v4.0.0 // Deprecated
File string `yaml:"file"` File string `yaml:"file"`
// Type of store // Type of store
// If blank, uses the default in-memory store // If blank, uses the default in-memory store
Type Type `yaml:"type"` Type Type `yaml:"type"`
} }
// ValidateAndSetDefaults validates the configuration and sets the default values (if applicable)
func (c *Config) ValidateAndSetDefaults() error {
if len(c.File) > 0 && len(c.Path) > 0 { // XXX: Remove for v4.0.0
return ErrCannotSetBothFileAndPath
} else if len(c.File) > 0 { // XXX: Remove for v4.0.0
log.Println("WARNING: Your configuration is using 'storage.file', which is deprecated in favor of 'storage.path'")
log.Println("WARNING: storage.file will be completely removed in v4.0.0, so please update your configuration")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/197")
c.Path = c.File
}
if c.Type == "" {
c.Type = TypeMemory
}
if (c.Type == TypePostgres || c.Type == TypeSQLite) && len(c.Path) == 0 {
return ErrSQLStorageRequiresFile
}
if c.Type == TypeMemory && len(c.Path) > 0 {
log.Println("WARNING: Your configuration is using a storage of type memory with persistence, which has been deprecated")
log.Println("WARNING: As of v4.0.0, the default storage type (memory) will not support persistence.")
log.Println("WARNING: If you want persistence, use 'storage.type: sqlite' instead of 'storage.type: memory'")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/198")
// XXX: Uncomment the following line for v4.0.0
//return ErrMemoryStorageDoesNotSupportFile
}
return nil
}

View File

@@ -1,91 +0,0 @@
package storage
import (
"context"
"log"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/memory"
"github.com/TwiN/gatus/v3/storage/store/sql"
)
var (
provider store.Store
// initialized keeps track of whether the storage provider was initialized
// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection
// every single time Get is called, we'll just lazily keep track of its existence through this variable
initialized bool
ctx context.Context
cancelFunc context.CancelFunc
)
// Get retrieves the storage provider
func Get() store.Store {
if !initialized {
log.Println("[storage][Get] Provider requested before it was initialized, automatically initializing")
err := Initialize(nil)
if err != nil {
panic("failed to automatically initialize store: " + err.Error())
}
}
return provider
}
// Initialize instantiates the storage provider based on the Config provider
func Initialize(cfg *Config) error {
initialized = true
var err error
if cancelFunc != nil {
// Stop the active autoSaveStore task, if there's already one
cancelFunc()
}
if cfg == nil {
cfg = &Config{}
}
if len(cfg.File) == 0 && cfg.Type != TypePostgres {
log.Printf("[storage][Initialize] Creating storage provider with type=%s and file=%s", cfg.Type, cfg.File)
} else {
log.Printf("[storage][Initialize] Creating storage provider with type=%s", cfg.Type)
}
ctx, cancelFunc = context.WithCancel(context.Background())
switch cfg.Type {
case TypeSQLite, TypePostgres:
provider, err = sql.NewStore(string(cfg.Type), cfg.File)
if err != nil {
return err
}
case TypeMemory:
fallthrough
default:
if len(cfg.File) > 0 {
provider, err = memory.NewStore(cfg.File)
if err != nil {
return err
}
go autoSaveStore(ctx, provider, 7*time.Minute)
} else {
provider, _ = memory.NewStore("")
}
}
return nil
}
// autoSaveStore automatically calls the Save function of the provider at every interval
func autoSaveStore(ctx context.Context, provider store.Store, interval time.Duration) {
for {
select {
case <-ctx.Done():
log.Printf("[storage][autoSaveStore] Stopping active job")
return
case <-time.After(interval):
log.Printf("[storage][autoSaveStore] Saving")
err := provider.Save()
if err != nil {
log.Println("[storage][autoSaveStore] Save failed:", err.Error())
}
}
}
}

View File

@@ -1,94 +0,0 @@
package storage
import (
"testing"
"time"
"github.com/TwiN/gatus/v3/storage/store/sql"
)
func TestGet(t *testing.T) {
store := Get()
if store == nil {
t.Error("store should've been automatically initialized")
}
}
func TestInitialize(t *testing.T) {
type Scenario struct {
Name string
Cfg *Config
ExpectedErr error
}
scenarios := []Scenario{
{
Name: "nil",
Cfg: nil,
ExpectedErr: nil,
},
{
Name: "blank",
Cfg: &Config{},
ExpectedErr: nil,
},
{
Name: "memory-no-file",
Cfg: &Config{Type: TypeMemory},
ExpectedErr: nil,
},
{
Name: "memory-with-file",
Cfg: &Config{Type: TypeMemory, File: t.TempDir() + "/TestInitialize_memory-with-file.db"},
ExpectedErr: nil,
},
{
Name: "sqlite-no-file",
Cfg: &Config{Type: TypeSQLite},
ExpectedErr: sql.ErrFilePathNotSpecified,
},
{
Name: "sqlite-with-file",
Cfg: &Config{Type: TypeSQLite, File: t.TempDir() + "/TestInitialize_sqlite-with-file.db"},
ExpectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := Initialize(scenario.Cfg)
if err != scenario.ExpectedErr {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
}
if err != nil {
return
}
if cancelFunc == nil {
t.Error("cancelFunc shouldn't have been nil")
}
if ctx == nil {
t.Error("ctx shouldn't have been nil")
}
if provider == nil {
t.Fatal("provider shouldn't have been nit")
}
provider.Close()
// Try to initialize it again
err = Initialize(scenario.Cfg)
if err != scenario.ExpectedErr {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
return
}
provider.Close()
})
}
}
func TestAutoSave(t *testing.T) {
file := t.TempDir() + "/TestAutoSave.db"
if err := Initialize(&Config{File: file}); err != nil {
t.Fatal("shouldn't have returned an error")
}
go autoSaveStore(ctx, provider, 3*time.Millisecond)
time.Sleep(15 * time.Millisecond)
cancelFunc()
time.Sleep(50 * time.Millisecond)
}

View File

@@ -2,7 +2,11 @@ package memory
import ( import (
"encoding/gob" "encoding/gob"
"io/fs"
"io/ioutil"
"log"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
@@ -24,6 +28,10 @@ func init() {
// Store that leverages gocache // Store that leverages gocache
type Store struct { type Store struct {
sync.RWMutex sync.RWMutex
// Deprecated
//
// File persistence will no longer be supported as of v4.0.0
// XXX: Remove me in v4.0.0
file string file string
cache *gocache.Cache cache *gocache.Cache
} }
@@ -37,9 +45,24 @@ func NewStore(file string) (*Store, error) {
file: file, file: file,
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize), cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
} }
// XXX: Remove the block below in v4.0.0 because persistence with the memory store will no longer be supported
// XXX: Make sure to also update gocache to v2.0.0
if len(file) > 0 { if len(file) > 0 {
_, err := store.cache.ReadFromFile(file) _, err := store.cache.ReadFromFile(file)
if err != nil { if err != nil {
// XXX: Remove the block below in v4.0.0
if data, err2 := ioutil.ReadFile(file); err2 == nil {
isFromOldVersion := strings.Contains(string(data), "*core.ServiceStatus")
if isFromOldVersion {
log.Println("WARNING: Couldn't read file due to recent change in v3.3.0, see https://github.com/TwiN/gatus/issues/191")
log.Println("WARNING: Will automatically rename old file to " + file + ".old and overwrite the current file")
if err = ioutil.WriteFile(file+".old", data, fs.ModePerm); err != nil {
log.Println("WARNING: Tried my best to keep the old file, but it wasn't enough. Sorry, your file will be overwritten :(")
}
// Return the store regardless of whether there was an error or not
return store, nil
}
}
return nil, err return nil, err
} }
} }

View File

@@ -34,8 +34,8 @@ const (
) )
var ( var (
// ErrFilePathNotSpecified is the error returned when path parameter passed in NewStore is blank // ErrPathNotSpecified is the error returned when the path parameter passed in NewStore is blank
ErrFilePathNotSpecified = errors.New("file path cannot be empty") ErrPathNotSpecified = errors.New("path cannot be empty")
// ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank // ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank
ErrDatabaseDriverNotSpecified = errors.New("database driver cannot be empty") ErrDatabaseDriverNotSpecified = errors.New("database driver cannot be empty")
@@ -45,20 +45,20 @@ var (
// Store that leverages a database // Store that leverages a database
type Store struct { type Store struct {
driver, file string driver, path string
db *sql.DB db *sql.DB
} }
// NewStore initializes the database and creates the schema if it doesn't already exist in the file specified // NewStore initializes the database and creates the schema if it doesn't already exist in the path specified
func NewStore(driver, path string) (*Store, error) { func NewStore(driver, path string) (*Store, error) {
if len(driver) == 0 { if len(driver) == 0 {
return nil, ErrDatabaseDriverNotSpecified return nil, ErrDatabaseDriverNotSpecified
} }
if len(path) == 0 { if len(path) == 0 {
return nil, ErrFilePathNotSpecified return nil, ErrPathNotSpecified
} }
store := &Store{driver: driver, file: path} store := &Store{driver: driver, path: path}
var err error var err error
if store.db, err = sql.Open(driver, path); err != nil { if store.db, err = sql.Open(driver, path); err != nil {
return nil, err return nil, err
@@ -342,7 +342,7 @@ func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
query += fmt.Sprintf("$%d,", i+1) query += fmt.Sprintf("$%d,", i+1)
args = append(args, keys[i]) args = append(args, keys[i])
} }
query = query[:len(query)-1] + ")" // Remove the last comma and close the parenthesis query = query[:len(query)-1] + ")" // Remove the last comma and add the closing parenthesis
result, err = s.db.Exec(query, args...) result, err = s.db.Exec(query, args...)
} }
if err != nil { if err != nil {

View File

@@ -84,7 +84,7 @@ func TestNewStore(t *testing.T) {
if _, err := NewStore("", "TestNewStore.db"); err != ErrDatabaseDriverNotSpecified { if _, err := NewStore("", "TestNewStore.db"); err != ErrDatabaseDriverNotSpecified {
t.Error("expected error due to blank driver parameter") t.Error("expected error due to blank driver parameter")
} }
if _, err := NewStore("sqlite", ""); err != ErrFilePathNotSpecified { if _, err := NewStore("sqlite", ""); err != ErrPathNotSpecified {
t.Error("expected error due to blank path parameter") t.Error("expected error due to blank path parameter")
} }
if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db"); err != nil { if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db"); err != nil {
@@ -169,8 +169,8 @@ func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
} }
func TestStore_Persistence(t *testing.T) { func TestStore_Persistence(t *testing.T) {
file := t.TempDir() + "/TestStore_Persistence.db" path := t.TempDir() + "/TestStore_Persistence.db"
store, _ := NewStore("sqlite", file) store, _ := NewStore("sqlite", path)
store.Insert(&testEndpoint, &testSuccessfulResult) store.Insert(&testEndpoint, &testSuccessfulResult)
store.Insert(&testEndpoint, &testUnsuccessfulResult) store.Insert(&testEndpoint, &testUnsuccessfulResult)
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 { if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
@@ -188,7 +188,7 @@ func TestStore_Persistence(t *testing.T) {
t.Fatal("sanity check failed") t.Fatal("sanity check failed")
} }
store.Close() store.Close()
store, _ = NewStore("sqlite", file) store, _ = NewStore("sqlite", path)
defer store.Close() defer store.Close()
ssFromNewStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents)) ssFromNewStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 { if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 {

View File

@@ -1,9 +1,12 @@
package store package store
import ( import (
"context"
"log"
"time" "time"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v3/storage/store/common/paging" "github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/storage/store/memory" "github.com/TwiN/gatus/v3/storage/store/memory"
"github.com/TwiN/gatus/v3/storage/store/sql" "github.com/TwiN/gatus/v3/storage/store/sql"
@@ -56,3 +59,82 @@ var (
_ Store = (*memory.Store)(nil) _ Store = (*memory.Store)(nil)
_ Store = (*sql.Store)(nil) _ Store = (*sql.Store)(nil)
) )
var (
store Store
// initialized keeps track of whether the storage provider was initialized
// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection
// every single time Get is called, we'll just lazily keep track of its existence through this variable
initialized bool
ctx context.Context
cancelFunc context.CancelFunc
)
func Get() Store {
if !initialized {
log.Println("[store][Get] Provider requested before it was initialized, automatically initializing")
err := Initialize(nil)
if err != nil {
panic("failed to automatically initialize store: " + err.Error())
}
}
return store
}
// Initialize instantiates the storage provider based on the Config provider
func Initialize(cfg *storage.Config) error {
initialized = true
var err error
if cancelFunc != nil {
// Stop the active autoSave task, if there's already one
cancelFunc()
}
if cfg == nil {
cfg = &storage.Config{}
}
if len(cfg.Path) == 0 && cfg.Type != storage.TypePostgres {
log.Printf("[store][Initialize] Creating storage provider with type=%s and file=%s", cfg.Type, cfg.Path)
} else {
log.Printf("[store][Initialize] Creating storage provider with type=%s", cfg.Type)
}
ctx, cancelFunc = context.WithCancel(context.Background())
switch cfg.Type {
case storage.TypeSQLite, storage.TypePostgres:
store, err = sql.NewStore(string(cfg.Type), cfg.Path)
if err != nil {
return err
}
case storage.TypeMemory:
fallthrough
default:
if len(cfg.Path) > 0 {
store, err = memory.NewStore(cfg.Path)
if err != nil {
return err
}
go autoSave(ctx, store, 7*time.Minute)
} else {
store, _ = memory.NewStore("")
}
}
return nil
}
// autoSave automatically calls the Save function of the provider at every interval
func autoSave(ctx context.Context, store Store, interval time.Duration) {
for {
select {
case <-ctx.Done():
log.Printf("[store][autoSave] Stopping active job")
return
case <-time.After(interval):
log.Printf("[store][autoSave] Saving")
err := store.Save()
if err != nil {
log.Println("[store][autoSave] Save failed:", err.Error())
}
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging" "github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/storage/store/memory" "github.com/TwiN/gatus/v3/storage/store/memory"
@@ -520,3 +521,89 @@ func TestStore_DeleteAllEndpointStatusesNotInKeys(t *testing.T) {
}) })
} }
} }
func TestGet(t *testing.T) {
store := Get()
if store == nil {
t.Error("store should've been automatically initialized")
}
}
func TestInitialize(t *testing.T) {
type Scenario struct {
Name string
Cfg *storage.Config
ExpectedErr error
}
scenarios := []Scenario{
{
Name: "nil",
Cfg: nil,
ExpectedErr: nil,
},
{
Name: "blank",
Cfg: &storage.Config{},
ExpectedErr: nil,
},
{
Name: "memory-no-path",
Cfg: &storage.Config{Type: storage.TypeMemory},
ExpectedErr: nil,
},
{ // XXX: Remove for v4.0.0. See https://github.com/TwiN/gatus/issues/198
Name: "memory-with-path",
Cfg: &storage.Config{Type: storage.TypeMemory, Path: t.TempDir() + "/TestInitialize_memory-with-path.db"},
ExpectedErr: nil,
},
{
Name: "sqlite-no-path",
Cfg: &storage.Config{Type: storage.TypeSQLite},
ExpectedErr: sql.ErrPathNotSpecified,
},
{
Name: "sqlite-with-path",
Cfg: &storage.Config{Type: storage.TypeSQLite, Path: t.TempDir() + "/TestInitialize_sqlite-with-path.db"},
ExpectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := Initialize(scenario.Cfg)
if err != scenario.ExpectedErr {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
}
if err != nil {
return
}
if cancelFunc == nil {
t.Error("cancelFunc shouldn't have been nil")
}
if ctx == nil {
t.Error("ctx shouldn't have been nil")
}
if store == nil {
t.Fatal("provider shouldn't have been nit")
}
store.Close()
// Try to initialize it again
err = Initialize(scenario.Cfg)
if err != scenario.ExpectedErr {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
return
}
store.Close()
})
}
}
func TestAutoSave(t *testing.T) {
file := t.TempDir() + "/TestAutoSave.db"
if err := Initialize(&storage.Config{Path: file}); err != nil {
t.Fatal("shouldn't have returned an error")
}
go autoSave(ctx, store, 3*time.Millisecond)
time.Sleep(15 * time.Millisecond)
cancelFunc()
time.Sleep(50 * time.Millisecond)
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v3/config/maintenance" "github.com/TwiN/gatus/v3/config/maintenance"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/metric" "github.com/TwiN/gatus/v3/metric"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v3/storage/store"
) )
var ( var (
@@ -29,13 +29,13 @@ func Monitor(cfg *config.Config) {
for _, endpoint := range cfg.Endpoints { for _, endpoint := range cfg.Endpoints {
if endpoint.IsEnabled() { if endpoint.IsEnabled() {
// To prevent multiple requests from running at the same time, we'll wait for a little before each iteration // To prevent multiple requests from running at the same time, we'll wait for a little before each iteration
time.Sleep(1111 * time.Millisecond) time.Sleep(777 * time.Millisecond)
go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.DisableMonitoringLock, cfg.Metrics, cfg.Debug, ctx) go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.DisableMonitoringLock, cfg.Metrics, cfg.Debug, ctx)
} }
} }
} }
// monitor monitors a single endpoint in a loop // monitor a single endpoint in a loop
func monitor(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, disableMonitoringLock, enabledMetrics, debug bool, ctx context.Context) { func monitor(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, disableMonitoringLock, enabledMetrics, debug bool, ctx context.Context) {
// Run it immediately on start // Run it immediately on start
execute(endpoint, alertingConfig, maintenanceConfig, disableMonitoringLock, enabledMetrics, debug) execute(endpoint, alertingConfig, maintenanceConfig, disableMonitoringLock, enabledMetrics, debug)
@@ -88,7 +88,7 @@ func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan
// UpdateEndpointStatuses updates the slice of endpoint statuses // UpdateEndpointStatuses updates the slice of endpoint statuses
func UpdateEndpointStatuses(endpoint *core.Endpoint, result *core.Result) { func UpdateEndpointStatuses(endpoint *core.Endpoint, result *core.Result) {
if err := storage.Get().Insert(endpoint, result); err != nil { if err := store.Get().Insert(endpoint, result); err != nil {
log.Println("[watchdog][UpdateEndpointStatuses] Failed to insert data in storage:", err.Error()) log.Println("[watchdog][UpdateEndpointStatuses] Failed to insert data in storage:", err.Error())
} }
} }