diff --git a/api/check_handler.go b/api/check_handler.go new file mode 100644 index 0000000..cb3bdc8 --- /dev/null +++ b/api/check_handler.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/baez90/nurse/check" +) + +var _ http.Handler = (*CheckHandler)(nil) + +type CheckHandler struct { + Timeout time.Duration + Check check.SystemChecker +} + +func (c CheckHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + var ( + ctx = request.Context() + cancel context.CancelFunc + ) + if c.Timeout != 0 { + ctx, cancel = context.WithTimeout(ctx, c.Timeout) + defer cancel() + } + if err := c.Check.Execute(ctx); err != nil { + writer.WriteHeader(http.StatusServiceUnavailable) + _, _ = writer.Write([]byte(err.Error())) + return + } + + writer.WriteHeader(200) + return +} diff --git a/api/mux.go b/api/mux.go new file mode 100644 index 0000000..53d17c4 --- /dev/null +++ b/api/mux.go @@ -0,0 +1,31 @@ +package api + +import ( + "net/http" + + "go.uber.org/zap" + + "github.com/baez90/nurse/check" + "github.com/baez90/nurse/config" +) + +func PrepareMux(instance *config.Nurse, modLookup check.ModuleLookup, srvLookup config.ServerLookup) (http.Handler, error) { + mux := http.NewServeMux() + + logger := zap.L() + + for route, spec := range instance.Endpoints { + logger.Info("Configuring route", zap.String("route", route.String())) + chk, err := check.CheckForScript(spec.Checks, modLookup, srvLookup) + if err != nil { + return nil, err + } + + mux.Handle(route.String(), CheckHandler{ + Timeout: spec.Timeout(instance.CheckTimeout), + Check: chk, + }) + } + + return mux, nil +} diff --git a/check/api.go b/check/api.go index 91baecc..bcb5d25 100644 --- a/check/api.go +++ b/check/api.go @@ -26,4 +26,12 @@ type ( CallUnmarshaler interface { UnmarshalCall(c grammar.Call) error } + + CheckerLookup interface { + Lookup(c grammar.Check, srvLookup config.ServerLookup) (SystemChecker, error) + } + + ModuleLookup interface { + Lookup(modName string) (CheckerLookup, error) + } ) diff --git a/check/collection.go b/check/collection.go new file mode 100644 index 0000000..f5ab019 --- /dev/null +++ b/check/collection.go @@ -0,0 +1,31 @@ +package check + +import ( + "context" + + "golang.org/x/sync/errgroup" + + "github.com/baez90/nurse/config" + "github.com/baez90/nurse/grammar" +) + +var _ SystemChecker = (Collection)(nil) + +type Collection []SystemChecker + +func (Collection) UnmarshalCheck(grammar.Check, config.ServerLookup) error { + panic("unmarshalling is not supported for a collection") +} + +func (c Collection) Execute(ctx context.Context) error { + grp, grpCtx := errgroup.WithContext(ctx) + + for i := range c { + chk := c[i] + grp.Go(func() error { + return chk.Execute(grpCtx) + }) + } + + return grp.Wait() +} diff --git a/check/endpoint.go b/check/endpoint.go new file mode 100644 index 0000000..eab63c0 --- /dev/null +++ b/check/endpoint.go @@ -0,0 +1,27 @@ +package check + +import ( + "github.com/baez90/nurse/config" + "github.com/baez90/nurse/grammar" +) + +func CheckForScript(script []grammar.Check, lkp ModuleLookup, srvLookup config.ServerLookup) (SystemChecker, error) { + compiledChecks := make([]SystemChecker, 0, len(script)) + + for i := range script { + rawChk := script[i] + mod, err := lkp.Lookup(rawChk.Initiator.Module) + if err != nil { + return nil, err + } + + compiledCheck, err := mod.Lookup(rawChk, srvLookup) + if err != nil { + return nil, err + } + + compiledChecks = append(compiledChecks, compiledCheck) + } + + return Collection(compiledChecks), nil +} diff --git a/check/modules.go b/check/modules.go index ed34ee3..026c0ec 100644 --- a/check/modules.go +++ b/check/modules.go @@ -37,8 +37,9 @@ func WithCheck(name string, factory Factory) ModuleOption { }) } -func NewModule(opts ...ModuleOption) (*Module, error) { +func NewModule(name string, opts ...ModuleOption) (*Module, error) { m := &Module{ + name: name, knownChecks: make(map[string]Factory), } @@ -52,10 +53,15 @@ func NewModule(opts ...ModuleOption) (*Module, error) { } type Module struct { + name string lock sync.RWMutex knownChecks map[string]Factory } +func (m *Module) Name() string { + return m.name +} + func (m *Module) Lookup(c grammar.Check, srvLookup config.ServerLookup) (SystemChecker, error) { m.lock.RLock() defer m.lock.RUnlock() diff --git a/check/registry.go b/check/registry.go new file mode 100644 index 0000000..6174ed7 --- /dev/null +++ b/check/registry.go @@ -0,0 +1,53 @@ +package check + +import ( + "errors" + "fmt" + "strings" + "sync" +) + +var ( + ErrModuleNameConflict = errors.New("module name conflict") + ErrNoSuchModule = errors.New("no module of given name known") +) + +func NewRegistry() *Registry { + return &Registry{ + mods: make(map[string]*Module), + } +} + +type ( + Registry struct { + lock sync.RWMutex + mods map[string]*Module + } +) + +func (r *Registry) Register(module *Module) error { + r.lock.Lock() + defer r.lock.Unlock() + + modName := strings.ToLower(module.Name()) + + if _, ok := r.mods[modName]; ok { + return fmt.Errorf("%w: %s", ErrModuleNameConflict, modName) + } + + r.mods[modName] = module + return nil +} + +func (r *Registry) Lookup(modName string) (CheckerLookup, error) { + r.lock.RLock() + defer r.lock.RUnlock() + + modName = strings.ToLower(modName) + + if mod, ok := r.mods[modName]; !ok { + return nil, fmt.Errorf("%w: %s", ErrNoSuchModule, modName) + } else { + return mod, nil + } +} diff --git a/config/app_config.go b/config/app_config.go index 33edee1..3ccd014 100644 --- a/config/app_config.go +++ b/config/app_config.go @@ -38,6 +38,18 @@ type Nurse struct { CheckTimeout time.Duration } +func (n Nurse) ServerLookup() (*ServerRegister, error) { + register := NewServerRegister() + + for name, srv := range n.Servers { + if err := register.Register(name, srv); err != nil { + return nil, err + } + } + + return register, nil +} + // Merge merges the current Nurse instance with another one // giving the current instance precedence means no set value is overwritten func (n Nurse) Merge(other Nurse) Nurse { diff --git a/config/endpoints.go b/config/endpoints.go index 75549e5..331cc41 100644 --- a/config/endpoints.go +++ b/config/endpoints.go @@ -1,7 +1,6 @@ package config import ( - "encoding" "fmt" "path" "strings" @@ -10,8 +9,6 @@ import ( "github.com/baez90/nurse/grammar" ) -var _ encoding.TextUnmarshaler = (*EndpointSpec)(nil) - type Route string func (r Route) String() string { @@ -26,17 +23,25 @@ type EndpointSpec struct { Checks []grammar.Check } -func (e *EndpointSpec) UnmarshalText(text []byte) error { +func (s EndpointSpec) Timeout(fallback time.Duration) time.Duration { + if s.CheckTimeout != 0 { + return s.CheckTimeout + } + + return fallback +} + +func (s *EndpointSpec) Parse(text string) error { parser, err := grammar.NewParser[grammar.Script]() if err != nil { return err } - script, err := parser.Parse(string(text)) + script, err := parser.Parse(text) if err != nil { return err } - e.Checks = script.Checks + s.Checks = script.Checks return nil } diff --git a/config/env.go b/config/env.go index 340a4fa..f9227af 100644 --- a/config/env.go +++ b/config/env.go @@ -50,7 +50,7 @@ func EndpointsFromEnv() (map[Route]EndpointSpec, error) { endpointRoute := path.Join(Split(ToLower(Trim(Replace(key, EndpointKeyPrefix, "", -1), "_")), "_")...) spec := EndpointSpec{} - if err := spec.UnmarshalText([]byte(value)); err != nil { + if err := spec.Parse(value); err != nil { return nil, err } diff --git a/config/env_test.go b/config/env_test.go index 1deb3d4..fe3cff6 100644 --- a/config/env_test.go +++ b/config/env_test.go @@ -95,7 +95,7 @@ func TestEndpointsFromEnv(t *testing.T) { }, want: td.Map(make(map[config.Route]config.EndpointSpec), td.MapEntries{ config.Route("readiness"): td.Struct(config.EndpointSpec{}, td.StructFields{ - "Checks": td.Len(1), + "Script": td.Len(1), }), }), wantErr: false, @@ -107,7 +107,7 @@ func TestEndpointsFromEnv(t *testing.T) { }, want: td.Map(make(map[config.Route]config.EndpointSpec), td.MapEntries{ config.Route("readiness"): td.Struct(config.EndpointSpec{}, td.StructFields{ - "Checks": td.Len(2), + "Script": td.Len(2), }), }), wantErr: false, @@ -119,7 +119,7 @@ func TestEndpointsFromEnv(t *testing.T) { }, want: td.Map(make(map[config.Route]config.EndpointSpec), td.MapEntries{ config.Route("readiness/redis"): td.Struct(config.EndpointSpec{}, td.StructFields{ - "Checks": td.Len(2), + "Script": td.Len(2), }), }), wantErr: false, @@ -132,10 +132,10 @@ func TestEndpointsFromEnv(t *testing.T) { }, want: td.Map(make(map[config.Route]config.EndpointSpec), td.MapEntries{ config.Route("readiness"): td.Struct(config.EndpointSpec{}, td.StructFields{ - "Checks": td.Len(1), + "Script": td.Len(1), }), config.Route("liveness"): td.Struct(config.EndpointSpec{}, td.StructFields{ - "Checks": td.Len(1), + "Script": td.Len(1), }), }), wantErr: false, diff --git a/go.mod b/go.mod index e008f9c..e1069d8 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/testcontainers/testcontainers-go v0.13.0 go.uber.org/zap v1.21.0 golang.org/x/exp v0.0.0-20220428152302-39d4317da171 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index 600d269..a09034f 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,7 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -718,17 +719,15 @@ go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= @@ -835,6 +834,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/grammar/grammar.go b/grammar/grammar.go index 89e4b16..33993e4 100644 --- a/grammar/grammar.go +++ b/grammar/grammar.go @@ -1,5 +1,16 @@ package grammar +import ( + "encoding/json" + + "gopkg.in/yaml.v3" +) + +var ( + _ json.Unmarshaler = (*Check)(nil) + _ yaml.Unmarshaler = (*Check)(nil) +) + type Call struct { Module string `parser:"(@Module'.')?"` Name string `parser:"@Ident"` @@ -15,6 +26,34 @@ type Check struct { Validators *Filters `parser:"( '=>' @@)?"` } +func (c *Check) UnmarshalYAML(value *yaml.Node) error { + parser, err := NewParser[Check]() + if err != nil { + return err + } + chk, err := parser.Parse(value.Value) + if err != nil { + return err + } + + *c = *chk + return nil +} + +func (c *Check) UnmarshalJSON(bytes []byte) error { + parser, err := NewParser[Check]() + if err != nil { + return err + } + chk, err := parser.ParseBytes(bytes) + if err != nil { + return err + } + + *c = *chk + return nil +} + type Script struct { Checks []Check `parser:"(@@';'?)*"` } diff --git a/grammar/parser.go b/grammar/parser.go index b4e3165..d212d52 100644 --- a/grammar/parser.go +++ b/grammar/parser.go @@ -49,3 +49,12 @@ func (p Parser[T]) Parse(rawRule string) (*T, error) { return into, nil } + +func (p Parser[T]) ParseBytes(data []byte) (*T, error) { + into := new(T) + if err := p.grammarParser.ParseBytes("", data, into); err != nil { + return nil, err + } + + return into, nil +} diff --git a/grammar/parser_test.go b/grammar/parser_test.go index f81bd5b..2e9390f 100644 --- a/grammar/parser_test.go +++ b/grammar/parser_test.go @@ -10,7 +10,7 @@ import ( ) var wantParsedScript = td.Struct(new(grammar.Script), td.StructFields{ - "Checks": td.Bag( + "Script": td.Bag( grammar.Check{ Initiator: &grammar.Call{ Module: "http", diff --git a/main.go b/main.go index 727ca1d..6e4d630 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,18 @@ package main import ( + "errors" "log" + "net/http" "os" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "github.com/baez90/nurse/api" + "github.com/baez90/nurse/check" "github.com/baez90/nurse/config" + "github.com/baez90/nurse/protocols/redis" ) var ( @@ -24,7 +29,7 @@ func main() { logger := zap.L() - envCfg, err := config.New( + nurseInstance, err := config.New( config.WithValuesFrom(cfg), config.WithConfigFile(cfgFile), config.WithServersFromEnv(), @@ -32,11 +37,33 @@ func main() { ) if err != nil { - logger.Error("Failed to load config from environment", zap.Error(err)) - os.Exit(1) + logger.Fatal("Failed to load config from environment", zap.Error(err)) } - logger.Debug("Loaded config", zap.Any("config", envCfg)) + logger.Debug("Loaded config", zap.Any("config", nurseInstance)) + + chkRegistry := check.NewRegistry() + if err := chkRegistry.Register(redis.Module()); err != nil { + logger.Fatal("Failed to register Redis module", zap.Error(err)) + } + + srvLookup, err := nurseInstance.ServerLookup() + if err != nil { + logger.Fatal("Failed to prepare server lookup", zap.Error(err)) + } + + mux, err := api.PrepareMux(nurseInstance, chkRegistry, srvLookup) + if err != nil { + logger.Fatal("Failed to prepare server mux", zap.Error(err)) + } + + if err := http.ListenAndServe(":8080", mux); err != nil { + if errors.Is(err, http.ErrServerClosed) { + return + } + + logger.Fatal("Failed to serve HTTP", zap.Error(err)) + } } func setupLogging() { diff --git a/redis/checks.go b/protocols/redis/checks.go similarity index 96% rename from redis/checks.go rename to protocols/redis/checks.go index bdf4a85..b6b441e 100644 --- a/redis/checks.go +++ b/protocols/redis/checks.go @@ -6,6 +6,7 @@ import ( func Module() *check.Module { m, _ := check.NewModule( + "redis", check.WithCheck("ping", check.FactoryFunc(func() check.SystemChecker { return new(PingCheck) })), diff --git a/redis/checks_test.go b/protocols/redis/checks_test.go similarity index 98% rename from redis/checks_test.go rename to protocols/redis/checks_test.go index f46345c..4e7045f 100644 --- a/redis/checks_test.go +++ b/protocols/redis/checks_test.go @@ -12,7 +12,7 @@ import ( "github.com/baez90/nurse/config" "github.com/baez90/nurse/grammar" - "github.com/baez90/nurse/redis" + "github.com/baez90/nurse/protocols/redis" ) func TestChecks_Execute(t *testing.T) { diff --git a/redis/client.go b/protocols/redis/client.go similarity index 100% rename from redis/client.go rename to protocols/redis/client.go diff --git a/redis/container_test.go b/protocols/redis/container_test.go similarity index 100% rename from redis/container_test.go rename to protocols/redis/container_test.go diff --git a/redis/get.go b/protocols/redis/get.go similarity index 100% rename from redis/get.go rename to protocols/redis/get.go diff --git a/redis/ping.go b/protocols/redis/ping.go similarity index 100% rename from redis/ping.go rename to protocols/redis/ping.go diff --git a/redis/validation.go b/protocols/redis/validation.go similarity index 100% rename from redis/validation.go rename to protocols/redis/validation.go