Implement persistence

This commit is contained in:
TwinProduction
2021-02-02 23:06:34 -05:00
parent 9196f57487
commit 79bef8d391
26 changed files with 592 additions and 203 deletions

7
storage/config.go Normal file
View File

@ -0,0 +1,7 @@
package storage
// Config is the configuration for alerting providers
type Config struct {
// File is the path of the file to use when using file.Store
File string `yaml:"file"`
}

View File

@ -1,69 +0,0 @@
package storage
import (
"encoding/json"
"sync"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/util"
)
// InMemoryStore implements an in-memory store
type InMemoryStore struct {
serviceStatuses map[string]*core.ServiceStatus
serviceResultsMutex sync.RWMutex
}
// NewInMemoryStore returns an in-memory store. Note that the store acts as a singleton, so although new-ing
// up in-memory stores will give you a unique reference to a struct each time, all structs returned
// by this function will act on the same in-memory store.
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{
serviceStatuses: make(map[string]*core.ServiceStatus),
}
}
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
func (ims *InMemoryStore) GetAllAsJSON() ([]byte, error) {
ims.serviceResultsMutex.RLock()
serviceStatuses, err := json.Marshal(ims.serviceStatuses)
ims.serviceResultsMutex.RUnlock()
return serviceStatuses, err
}
// GetServiceStatus returns the service status for a given service name in the given group
func (ims *InMemoryStore) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus {
key := util.ConvertGroupAndServiceToKey(groupName, serviceName)
ims.serviceResultsMutex.RLock()
serviceStatus := ims.serviceStatuses[key]
ims.serviceResultsMutex.RUnlock()
return serviceStatus
}
// GetServiceStatusByKey returns the service status for a given key
func (ims *InMemoryStore) GetServiceStatusByKey(key string) *core.ServiceStatus {
ims.serviceResultsMutex.RLock()
serviceStatus := ims.serviceStatuses[key]
ims.serviceResultsMutex.RUnlock()
return serviceStatus
}
// Insert inserts the observed result for the specified service into the in memory store
func (ims *InMemoryStore) Insert(service *core.Service, result *core.Result) {
key := util.ConvertGroupAndServiceToKey(service.Group, service.Name)
ims.serviceResultsMutex.Lock()
serviceStatus, exists := ims.serviceStatuses[key]
if !exists {
serviceStatus = core.NewServiceStatus(service)
ims.serviceStatuses[key] = serviceStatus
}
serviceStatus.AddResult(result)
ims.serviceResultsMutex.Unlock()
}
// Clear will empty all the results from the in memory store
func (ims *InMemoryStore) Clear() {
ims.serviceResultsMutex.Lock()
ims.serviceStatuses = make(map[string]*core.ServiceStatus)
ims.serviceResultsMutex.Unlock()
}

48
storage/storage.go Normal file
View File

@ -0,0 +1,48 @@
package storage
import (
"log"
"time"
"github.com/TwinProduction/gatus/storage/store"
"github.com/TwinProduction/gatus/storage/store/memory"
)
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
)
// 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 cfg == nil || len(cfg.File) == 0 {
log.Println("[storage][Initialize] Creating storage provider")
provider, err = memory.NewStore("")
} else {
log.Printf("[storage][Initialize] Creating storage provider with file=%s", cfg.File)
provider, err = memory.NewStore(cfg.File)
if err != nil {
return err
}
go provider.(*memory.Store).AutoSave(7 * time.Minute)
}
return nil
}

View File

@ -0,0 +1,110 @@
package memory
import (
"encoding/gob"
"encoding/json"
"log"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/util"
"github.com/TwinProduction/gocache"
)
func init() {
gob.Register(&core.ServiceStatus{})
gob.Register(&core.Uptime{})
gob.Register(&core.Result{})
gob.Register(&core.Event{})
}
// Store that leverages gocache
type Store struct {
file string
cache *gocache.Cache
}
// NewStore creates a new store
func NewStore(file string) (*Store, error) {
store := &Store{
file: file,
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
}
if len(file) > 0 {
_, err := store.cache.ReadFromFile(file)
if err != nil {
return nil, err
}
}
return store, nil
}
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
func (s *Store) GetAllAsJSON() ([]byte, error) {
return json.Marshal(s.cache.GetAll())
}
// GetServiceStatus returns the service status for a given service name in the given group
func (s *Store) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus {
return s.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(groupName, serviceName))
}
// GetServiceStatusByKey returns the service status for a given key
func (s *Store) GetServiceStatusByKey(key string) *core.ServiceStatus {
serviceStatus := s.cache.GetValue(key)
if serviceStatus == nil {
return nil
}
return serviceStatus.(*core.ServiceStatus)
}
// Insert adds the observed result for the specified service into the store
func (s *Store) Insert(service *core.Service, result *core.Result) {
key := util.ConvertGroupAndServiceToKey(service.Group, service.Name)
serviceStatus, exists := s.cache.Get(key)
if !exists {
serviceStatus = core.NewServiceStatus(service)
}
serviceStatus.(*core.ServiceStatus).AddResult(result)
s.cache.Set(key, serviceStatus)
}
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
func (s *Store) DeleteAllServiceStatusesNotInKeys(keys []string) int {
var keysToDelete []string
for _, existingKey := range s.cache.GetKeysByPattern("*", 0) {
shouldDelete := true
for _, key := range keys {
if existingKey == key {
shouldDelete = false
break
}
}
if shouldDelete {
keysToDelete = append(keysToDelete, existingKey)
}
}
return s.cache.DeleteAll(keysToDelete)
}
// Clear deletes everything from the store
func (s *Store) Clear() {
s.cache.Clear()
}
// Save persists the cache to the store file
func (s *Store) Save() error {
return s.cache.SaveToFile(s.file)
}
// AutoSave automatically calls the Save function at every interval
func (s *Store) AutoSave(interval time.Duration) {
for {
time.Sleep(interval)
log.Printf("[memory][AutoSave] Persisting data to file")
err := s.Save()
if err != nil {
log.Printf("[memory][AutoSave] failed to save to file=%s: %s", s.file, err.Error())
}
}
}

View File

@ -1,4 +1,4 @@
package storage
package memory
import (
"fmt"
@ -83,17 +83,17 @@ var (
}
)
func TestInMemoryStore_Insert(t *testing.T) {
store := NewInMemoryStore()
func TestStore_Insert(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
if len(store.serviceStatuses) != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", len(store.serviceStatuses))
if store.cache.Count() != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", store.cache.Count())
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceStatus, exists := store.serviceStatuses[key]
if !exists {
serviceStatus := store.GetServiceStatusByKey(key)
if serviceStatus == nil {
t.Fatalf("Store should've had key '%s', but didn't", key)
}
if len(serviceStatus.Results) != 2 {
@ -140,8 +140,8 @@ func TestInMemoryStore_Insert(t *testing.T) {
}
}
func TestInMemoryStore_GetServiceStatus(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatus(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
@ -163,8 +163,8 @@ func TestInMemoryStore_GetServiceStatus(t *testing.T) {
}
}
func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
serviceStatus := store.GetServiceStatus("nonexistantgroup", "nonexistantname")
@ -181,8 +181,8 @@ func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T)
}
}
func TestInMemoryStore_GetServiceStatusByKey(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatusByKey(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
@ -204,8 +204,8 @@ func TestInMemoryStore_GetServiceStatusByKey(t *testing.T) {
}
}
func TestInMemoryStore_GetAllAsJSON(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetAllAsJSON(t *testing.T) {
store, _ := NewStore("")
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Insert(&testService, firstResult)
@ -217,8 +217,36 @@ func TestInMemoryStore_GetAllAsJSON(t *testing.T) {
if err != nil {
t.Fatal("shouldn't have returned an error, got", err.Error())
}
expectedOutput := `{"group_name":{"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"}],"uptime":{"7d":0.5,"24h":0.5,"1h":0.5}}}`
expectedOutput := `{"group_name":{"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"}]}}`
if string(output) != expectedOutput {
t.Errorf("expected:\n %s\n\ngot:\n %s", expectedOutput, string(output))
}
}
func TestStore_DeleteAllServiceStatusesNotInKeys(t *testing.T) {
store, _ := NewStore("")
firstService := core.Service{Name: "service-1", Group: "group"}
secondService := core.Service{Name: "service-2", Group: "group"}
result := &testSuccessfulResult
store.Insert(&firstService, result)
store.Insert(&secondService, result)
if store.cache.Count() != 2 {
t.Errorf("expected cache to have 2 keys, got %d", store.cache.Count())
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
t.Fatal("firstService should exist")
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) == nil {
t.Fatal("secondService should exist")
}
store.DeleteAllServiceStatusesNotInKeys([]string{util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)})
if store.cache.Count() != 1 {
t.Fatalf("expected cache to have 1 keys, got %d", store.cache.Count())
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
t.Error("secondService should've been deleted")
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) != nil {
t.Error("firstService should still exist")
}
}

34
storage/store/store.go Normal file
View File

@ -0,0 +1,34 @@
package store
import (
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/memory"
)
// Store is the interface that each stores should implement
type Store interface {
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
GetAllAsJSON() ([]byte, error)
// GetServiceStatus returns the service status for a given service name in the given group
GetServiceStatus(groupName, serviceName string) *core.ServiceStatus
// GetServiceStatusByKey returns the service status for a given key
GetServiceStatusByKey(key string) *core.ServiceStatus
// Insert adds the observed result for the specified service into the store
Insert(service *core.Service, result *core.Result)
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
//
// Used to delete services that have been persisted but are no longer part of the configured services
DeleteAllServiceStatusesNotInKeys(keys []string) int
// Clear deletes everything from the store
Clear()
}
var (
// Validate interface implementation on compile
_ Store = (*memory.Store)(nil)
)

View File

@ -0,0 +1,136 @@
package store
import (
"testing"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/memory"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Body: []byte("body"),
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Body: []byte("body"),
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func BenchmarkStore_GetAllAsJSON(b *testing.B) {
memoryStore, err := memory.NewStore("")
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
type Scenario struct {
Name string
Store Store
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
},
}
for _, scenario := range scenarios {
scenario.Store.Insert(&testService, &testSuccessfulResult)
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
b.Run(scenario.Name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
scenario.Store.GetAllAsJSON()
}
b.ReportAllocs()
})
}
}
func BenchmarkStore_Insert(b *testing.B) {
memoryStore, err := memory.NewStore("")
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
type Scenario struct {
Name string
Store Store
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
},
}
for _, scenario := range scenarios {
b.Run(scenario.Name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
scenario.Store.Insert(&testService, &testSuccessfulResult)
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
}
b.ReportAllocs()
})
}
}