Add basic Redis checks

This commit is contained in:
Peter 2022-04-28 18:35:02 +02:00
parent 1b4f632048
commit 803f52ba46
Signed by: prskr
GPG key ID: C1DB5D2E8DB512F9
30 changed files with 2584 additions and 1 deletions

27
.editorconfig Normal file
View file

@ -0,0 +1,27 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
tab_width = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
trim_trailing_whitespace = true
[*.{go, go2}]
indent_style = tab
ij_smart_tabs = true
ij_go_GROUP_CURRENT_PROJECT_IMPORTS = true
ij_go_group_stdlib_imports = true
ij_go_import_sorting = goimports
ij_go_local_group_mode = project
ij_go_move_all_imports_in_one_declaration = true
ij_go_move_all_stdlib_imports_in_one_group = true
ij_go_remove_redundant_import_aliases = true
[{*.yaml, *.yml}]
indent_size = 2
tab_width = 2
insert_final_newline = true

44
.github/workflows/go.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Go
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
pull-requests: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.18
uses: actions/setup-go@v3
with:
go-version: '^1.18'
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
with:
lfs: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
only-new-issues: true
skip-go-installation: true
- run: go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View file

@ -13,3 +13,6 @@
# Dependency directories (remove the comment below to include it)
# vendor/
dist/
.idea/

138
.golangci.yml Normal file
View file

@ -0,0 +1,138 @@
linters-settings:
dupl:
threshold: 100
funlen:
lines: 100
statements: 50
gci:
local-prefixes: github.com/baez90/nurse
goconst:
min-len: 2
min-occurrences: 2
gocritic:
enabled-tags:
- diagnostic
- opinionated
- performance
disabled-checks:
- ifElseChain
- octalLiteral
- wrapperFunc
# see https://github.com/golangci/golangci-lint/issues/2649
- hugeParam
- rangeValCopy
# settings:
# hugeParam:
# sizeThreshold: 200
gocyclo:
min-complexity: 15
goimports:
local-prefixes: github.com/baez90/nurse
golint:
min-confidence: 0
gomnd:
settings:
mnd:
# don't include the "operation" and "assign"
checks:
- argument
- case
- condition
- return
gomoddirectives:
replace-allow-list: []
govet:
check-shadowing: true
enable-all: true
disable:
- fieldalignment
# see https://github.com/golangci/golangci-lint/issues/2649
- nilness
- unusedwrite
importas:
no-unaliased: true
lll:
line-length: 140
misspell:
locale: US
nolintlint:
allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space)
allow-unused: false # report any unused nolint directives
require-explanation: false # don't require an explanation for nolint directives
require-specific: true
linters:
disable-all: true
enable:
- contextcheck
- deadcode
- dogsled
- dupl
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exportloopref
- funlen
- gocognit
- goconst
# - gocritic
- gocyclo
- godox
- gofumpt
- goimports
- gomoddirectives
- gomnd
- gosec
- gosimple
- govet
- ifshort
- importas
- ineffassign
# - ireturn - enable later
- lll
- misspell
- nakedret
- nestif
- nilnil
- noctx
- nolintlint
- paralleltest
- prealloc
- predeclared
- promlinter
- staticcheck
- structcheck
- stylecheck
- tenv
- testpackage
- thelper
- typecheck
- unconvert
- unparam
- varcheck
- whitespace
# - unused
- wastedassign
issues:
# Excluding configuration per-path, per-linter, per-text and per-source
exclude-rules:
- path: _test\.go
linters:
- dupl
- funlen
- gocognit
- gomnd
- govet
- path: magefiles/
linters:
- deadcode
run:
skip-files:
- ".*.mock.\\.go$"
modules-download-mode: readonly
timeout: 5m

41
.goreleaser.yaml Normal file
View file

@ -0,0 +1,41 @@
before:
hooks:
- go mod tidy
builds:
- id: nurse
binary: nurse
flags:
- -trimpath
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
dockers:
- ids:
- nurse
image_templates:
- ghcr.io/baez90/nurse:latest
- ghcr.io/baez90/nurse:{{ .Tag }}
- ghcr.io/baez90/nurse:{{ .Major }}
- ghcr.io/baez90/nurse:{{ .ShortCommit}}
dockerfile: deployments/Dockerfile

20
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,20 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-beta.5
hooks:
- id: go-mod-tidy-repo
args:
- -go=1.18
- id: go-fumpt
args:
- -w
- id: go-imports
args:
- -local=gitlab.com/inetmock/inetmock
- -w
- id: golangci-lint-repo-mod
args:
- --fast
- --fix

View file

@ -1,2 +1,3 @@
# nurse
# Nurse
A generic service health sidecar

15
check/api.go Normal file
View file

@ -0,0 +1,15 @@
package check
import (
"context"
"errors"
"github.com/baez90/nurse/grammar"
)
var ErrNoSuchCheck = errors.New("no such check")
type SystemChecker interface {
grammar.CheckUnmarshaler
Execute(ctx context.Context) error
}

48
config/lookup.go Normal file
View file

@ -0,0 +1,48 @@
package config
import (
"errors"
"fmt"
"strings"
"sync"
)
var (
ErrServerNameAlreadyRegistered = errors.New("server name is already registered")
ErrNoSuchServer = errors.New("no known server with given name")
DefaultLookup = &ServerLookup{
servers: make(map[string]Server),
}
)
type ServerLookup struct {
lock sync.RWMutex
servers map[string]Server
}
func (l *ServerLookup) Register(name string, srv Server) error {
l.lock.Lock()
defer l.lock.Unlock()
name = strings.ToLower(name)
if _, ok := l.servers[name]; ok {
return fmt.Errorf("%w: %s", ErrServerNameAlreadyRegistered, name)
}
l.servers[name] = srv
return nil
}
func (l *ServerLookup) Lookup(name string) (*Server, error) {
l.lock.RLock()
defer l.lock.RUnlock()
name = strings.ToLower(name)
match, ok := l.servers[name]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrNoSuchServer, name)
}
return &match, nil
}

14
config/schemes.go Normal file
View file

@ -0,0 +1,14 @@
package config
import "strings"
var scheme2ServerType = map[string]ServerType{
"redis": ServerTypeRedis,
}
func SchemeToServerType(scheme string) ServerType {
if match, ok := scheme2ServerType[strings.ToLower(scheme)]; ok {
return match
}
return ServerTypeUnspecified
}

65
config/server.go Normal file
View file

@ -0,0 +1,65 @@
package config
import (
"encoding/json"
"net/url"
"regexp"
"strings"
"github.com/baez90/nurse/internal/values"
)
type ServerType uint
const (
ServerTypeUnspecified ServerType = iota
ServerTypeRedis
)
var hostsRegexp = regexp.MustCompile(`^{(.+:\d{1,5})(,(.+:\d{1,5}))*}|(.+:\d{1,5})$`)
func ParseFromURL(url *url.URL) (*Server, error) {
srv := &Server{
Type: SchemeToServerType(url.Scheme),
Hosts: hostsRegexp.FindAllString(url.Host, -1),
}
if user := url.User; user != nil {
srv.Credentials = &Credentials{
Username: user.Username(),
}
if pw, ok := user.Password(); ok {
srv.Credentials.Password = values.StringP(pw)
}
}
srv.Path = strings.Split(strings.Trim(url.EscapedPath(), "/"), "/")
q := url.Query()
qm := map[string][]string(q)
srv.Args = make(map[string]any, len(qm))
for k := range qm {
var val any
if err := json.Unmarshal([]byte(q.Get(k)), &val); err != nil {
return nil, err
} else {
srv.Args[k] = val
}
}
return srv, nil
}
type Credentials struct {
Username string
Password *string
}
type Server struct {
Type ServerType
Credentials *Credentials
Hosts []string
Path []string
Args map[string]any
}

5
deployments/Dockerfile Normal file
View file

@ -0,0 +1,5 @@
FROM gcr.io/distroless/static:nonroot
COPY nurse /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/nurse"]

48
go.mod Normal file
View file

@ -0,0 +1,48 @@
module github.com/baez90/nurse
go 1.18
require (
github.com/alecthomas/participle/v2 v2.0.0-alpha8
github.com/go-redis/redis/v8 v8.11.5
github.com/maxatome/go-testdeep v1.11.0
github.com/mitchellh/mapstructure v1.4.3
github.com/testcontainers/testcontainers-go v0.13.0
)
require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/Microsoft/hcsshim v0.9.2 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/containerd/cgroups v1.0.3 // indirect
github.com/containerd/containerd v1.6.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/docker v20.10.14+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/moby/sys/mount v0.3.2 // indirect
github.com/moby/sys/mountinfo v0.6.1 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/opencontainers/runc v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be // indirect
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4 // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

1136
go.sum Normal file

File diff suppressed because it is too large Load diff

9
grammar/errors.go Normal file
View file

@ -0,0 +1,9 @@
package grammar
import "errors"
var (
ErrMissingServer = errors.New("initiator is missing a server")
ErrTypeMismatch = errors.New("param has a different type")
ErrAmbiguousParamCount = errors.New("the supplied number of arguments does not match the expected one")
)

24
grammar/grammar.go Normal file
View file

@ -0,0 +1,24 @@
package grammar
type CheckUnmarshaler interface {
UnmarshalCheck(c Check) error
}
type Call struct {
Module string `parser:"(@Module'.')?"`
Name string `parser:"@Ident"`
Params []Param `parser:"'(' @@? ( ',' @@ )*')'"`
}
type Filters struct {
Chain []Call `parser:"@@ ('->' @@)*"`
}
type Check struct {
Initiator *Call `parser:"@@"`
Validators *Filters `parser:"( '=>' @@)?"`
}
type Script struct {
Checks []Check `parser:"@@*"`
}

39
grammar/param.go Normal file
View file

@ -0,0 +1,39 @@
package grammar
import (
"fmt"
)
func ValidateParameterCount(params []Param, expected int) error {
if len(params) < expected {
return fmt.Errorf("%w: expected %d got %d", ErrAmbiguousParamCount, expected, len(params))
}
return nil
}
type Param struct {
String *string `parser:"@String | @RawString"`
Int *int `parser:"| @Int"`
Float *float64 `parser:"| @Float"`
}
func (p Param) AsString() (string, error) {
if p.String == nil {
return "", fmt.Errorf("string is nil %w", ErrTypeMismatch)
}
return *p.String, nil
}
func (p Param) AsInt() (int, error) {
if p.Int == nil {
return 0, fmt.Errorf("int is nil %w", ErrTypeMismatch)
}
return *p.Int, nil
}
func (p Param) AsFloat() (float64, error) {
if p.Float == nil {
return 0, fmt.Errorf("float is nil %w", ErrTypeMismatch)
}
return *p.Float, nil
}

159
grammar/param_test.go Normal file
View file

@ -0,0 +1,159 @@
package grammar_test
import (
"testing"
"github.com/baez90/nurse/grammar"
"github.com/baez90/nurse/internal/values"
)
func TestParam_AsString(t *testing.T) {
t.Parallel()
type fields struct {
String *string
}
tests := []struct {
name string
fields fields
want string
wantErr bool
}{
{
name: "Empty string",
fields: fields{
String: values.StringP(""),
},
want: "",
},
{
name: "Any string",
fields: fields{
String: values.StringP("Hello, world!"),
},
want: "Hello, world!",
},
{
name: "nil value",
wantErr: true,
},
}
for _, tc := range tests {
tt := tc
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := grammar.Param{
String: tt.fields.String,
}
got, err := p.AsString()
if (err != nil) != tt.wantErr {
t.Errorf("AsString() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("AsString() got = %v, want %v", got, tt.want)
}
})
}
}
func TestParam_AsInt(t *testing.T) {
t.Parallel()
type fields struct {
Int *int
}
tests := []struct {
name string
fields fields
want int
wantErr bool
}{
{
name: "zero value",
fields: fields{
Int: values.IntP(0),
},
want: 0,
},
{
name: "Any int",
fields: fields{
Int: values.IntP(42),
},
want: 42,
},
{
name: "nil value",
wantErr: true,
},
}
for _, tc := range tests {
tt := tc
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := grammar.Param{
Int: tt.fields.Int,
}
got, err := p.AsInt()
if (err != nil) != tt.wantErr {
t.Errorf("AsInt() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("AsInt() got = %v, want %v", got, tt.want)
}
})
}
}
func TestParam_AsFloat(t *testing.T) {
t.Parallel()
type fields struct {
Float *float64
}
tests := []struct {
name string
fields fields
want float64
wantErr bool
}{
{
name: "Zero value",
fields: fields{
Float: values.FloatP(0),
},
want: 0,
},
{
name: "Any value",
fields: fields{
Float: values.FloatP(13.37),
},
want: 13.37,
},
{
name: "nil value",
wantErr: true,
},
}
for _, tc := range tests {
tt := tc
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := grammar.Param{
Float: tt.fields.Float,
}
got, err := p.AsFloat()
if (err != nil) != tt.wantErr {
t.Errorf("AsFloat() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("AsFloat() got = %v, want %v", got, tt.want)
}
})
}
}
func params(p ...grammar.Param) []grammar.Param {
return p
}

51
grammar/parser.go Normal file
View file

@ -0,0 +1,51 @@
package grammar
import (
"github.com/alecthomas/participle/v2"
"github.com/alecthomas/participle/v2/lexer"
)
func NewParser[T any]() (*Parser[T], error) {
def, err := lexer.NewSimple([]lexer.SimpleRule{
{Name: "Comment", Pattern: `(?:#|//)[^\n]*\n?`},
{Name: `Module`, Pattern: `[a-z]{1}[A-z0-9]+`},
{Name: `Ident`, Pattern: `[A-Z][a-zA-Z0-9_]*`},
{Name: `CIDR`, Pattern: `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/(3[0-2]|[1-2][0-9]|[1-9])`},
{Name: `IP`, Pattern: `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}`},
{Name: `Float`, Pattern: `\d+\.\d+`},
{Name: `Int`, Pattern: `[-]?\d+`},
{Name: `RawString`, Pattern: "`[^`]*`"},
{Name: `String`, Pattern: `'[^']*'|"[^"]*"`},
{Name: `Arrows`, Pattern: `(->|=>)`},
{Name: "whitespace", Pattern: `\s+`},
{Name: "Punct", Pattern: `[-[!@#$%^&*()+_={}\|:;\."'<,>?/]|]`},
})
if err != nil {
return nil, err
}
grammarParser, err := participle.Build(
new(T),
participle.Lexer(def),
participle.Unquote("String", "RawString"),
participle.Elide("Comment"),
)
if err != nil {
return nil, err
}
return &Parser[T]{grammarParser: grammarParser}, nil
}
type Parser[T any] struct {
grammarParser *participle.Parser
}
func (p Parser[T]) Parse(rawRule string) (*T, error) {
into := new(T)
if err := p.grammarParser.ParseString("", rawRule, into); err != nil {
return nil, err
}
return into, nil
}

224
grammar/parser_test.go Normal file
View file

@ -0,0 +1,224 @@
package grammar_test
import (
"testing"
"github.com/maxatome/go-testdeep/td"
"github.com/baez90/nurse/grammar"
"github.com/baez90/nurse/internal/values"
)
var wantParsedScript = td.Struct(new(grammar.Script), td.StructFields{
"Checks": td.Bag(
grammar.Check{
Initiator: &grammar.Call{
Module: "http",
Name: "Get",
Params: params(grammar.Param{String: values.StringP("https://www.gogol.com/")}),
},
Validators: &grammar.Filters{
Chain: []grammar.Call{
{
Name: "Status",
Params: []grammar.Param{
{
Int: values.IntP(404),
},
},
},
},
},
},
grammar.Check{
Initiator: &grammar.Call{
Module: "http",
Name: "Get",
Params: params(grammar.Param{String: values.StringP("https://www.microsoft.com/")}),
},
Validators: &grammar.Filters{
Chain: []grammar.Call{
{
Name: "Status",
Params: []grammar.Param{
{
Int: values.IntP(200),
},
},
},
{
Name: "Header",
Params: []grammar.Param{
{
String: values.StringP("Content-Type"),
},
{
String: values.StringP("text/html"),
},
},
},
},
},
},
),
})
func TestParser_Parse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawRule string
want any
wantErr bool
}{
{
name: "Check - Initiator only - string argument",
rawRule: `http.Get("https://www.microsoft.com/")`,
want: &grammar.Script{
Checks: []grammar.Check{
{
Initiator: &grammar.Call{
Module: "http",
Name: "Get",
Params: params(grammar.Param{String: values.StringP("https://www.microsoft.com/")}),
},
},
},
},
wantErr: false,
},
{
name: "Check - Initiator only - raw string argument",
rawRule: "http.Post(\"https://www.microsoft.com/\", `{\"Name\":\"Ted.Tester\"}`)",
want: &grammar.Script{
Checks: []grammar.Check{
{
Initiator: &grammar.Call{
Module: "http",
Name: "Post",
Params: []grammar.Param{
{
String: values.StringP("https://www.microsoft.com/"),
},
{
String: values.StringP(`{"Name":"Ted.Tester"}`),
},
},
},
},
},
},
},
{
name: "Check - Initiator and single filter",
rawRule: `http.Get("https://www.microsoft.com/") => Status(200)`,
want: &grammar.Script{
Checks: []grammar.Check{
{
Initiator: &grammar.Call{
Module: "http",
Name: "Get",
Params: params(grammar.Param{String: values.StringP("https://www.microsoft.com/")}),
},
Validators: &grammar.Filters{
Chain: []grammar.Call{
{
Name: "Status",
Params: []grammar.Param{
{
Int: values.IntP(200),
},
},
},
},
},
},
},
},
},
{
name: "Check - Initiator and multiple filters",
rawRule: `http.Get("https://www.microsoft.com/") => Status(200) -> Header("Content-Type", "text/html")`,
want: &grammar.Script{
Checks: []grammar.Check{
{
Initiator: &grammar.Call{
Module: "http",
Name: "Get",
Params: params(grammar.Param{String: values.StringP("https://www.microsoft.com/")}),
},
Validators: &grammar.Filters{
Chain: []grammar.Call{
{
Name: "Status",
Params: []grammar.Param{
{
Int: values.IntP(200),
},
},
},
{
Name: "Header",
Params: []grammar.Param{
{
String: values.StringP("Content-Type"),
},
{
String: values.StringP("text/html"),
},
},
},
},
},
},
},
},
},
{
name: "CheckScript without comments",
rawRule: `
http.Get("https://www.gogol.com/") => Status(404)
http.Get("https://www.microsoft.com/") => Status(200) -> Header("Content-Type", "text/html")
`,
want: wantParsedScript,
},
{
name: "CheckScript without comments - single line",
//nolint:lll // required at this point
rawRule: `http.Get("https://www.gogol.com/") => Status(404) http.Get("https://www.microsoft.com/") => Status(200) -> Header("Content-Type", "text/html")`,
want: wantParsedScript,
},
{
name: "CheckScript with comments",
rawRule: `
# GET https://www.gogol.com/ expect a not found response
http.Get("https://www.gogol.com/") => Status(404)
// GET https://www.microsoft.com/ - expect status OK and HTML content
http.Get("https://www.microsoft.com/") => Status(200) -> Header("Content-Type", "text/html")
`,
want: wantParsedScript,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := grammar.NewParser[grammar.Script]()
if err != nil {
t.Fatalf("NewParser() err = %v", err)
}
got, err := p.Parse(tt.rawRule)
if err != nil {
if !tt.wantErr {
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
}
return
}
td.Cmp(t, got, tt.want)
})
}
}

13
internal/values/refs.go Normal file
View file

@ -0,0 +1,13 @@
package values
func StringP(value string) *string {
return &value
}
func IntP(value int) *int {
return &value
}
func FloatP(value float64) *float64 {
return &value
}

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"os"
"time"
)
var checkTimeout time.Duration
func main() {
}
func lookupEnvOr[T any](envKey string, fallback T, parse func(envVal string) (T, error)) T {
envVal := os.Getenv(envKey)
if envVal == "" {
return fallback
}
if parsed, err := parse(envVal); err != nil {
return fallback
} else {
return parsed
}
}
func identity[T any](in T) (T, error) {
return in, nil
}

35
redis/checks.go Normal file
View file

@ -0,0 +1,35 @@
package redis
import (
"fmt"
"strings"
"github.com/baez90/nurse/check"
"github.com/baez90/nurse/grammar"
)
var knownChecks = map[string]func() check.SystemChecker{
"ping": func() check.SystemChecker {
return new(PingCheck)
},
"get": func() check.SystemChecker {
return new(GetCheck)
},
}
func LookupCheck(c grammar.Check) (check.SystemChecker, error) {
var (
provider func() check.SystemChecker
ok bool
)
if provider, ok = knownChecks[strings.ToLower(c.Initiator.Name)]; !ok {
return nil, fmt.Errorf("%w: %s", check.ErrNoSuchCheck, c.Initiator.Name)
}
chk := provider()
if err := chk.UnmarshalCheck(c); err != nil {
return nil, err
}
return chk, nil
}

41
redis/client.go Normal file
View file

@ -0,0 +1,41 @@
package redis
import (
"strconv"
"github.com/go-redis/redis/v8"
"github.com/mitchellh/mapstructure"
"github.com/baez90/nurse/config"
"github.com/baez90/nurse/grammar"
)
func clientFromParam(p grammar.Param) (redis.UniversalClient, error) {
if srvName, err := p.AsString(); err != nil {
return nil, err
} else if srv, err := config.DefaultLookup.Lookup(srvName); err != nil {
return nil, err
} else if redisCli, err := ClientForServer(srv); err != nil {
return nil, err
} else {
return redisCli, nil
}
}
func ClientForServer(srv *config.Server) (redis.UniversalClient, error) {
opts := &redis.UniversalOptions{
Addrs: srv.Hosts,
}
if pathLen := len(srv.Path); pathLen > 0 {
if db, err := strconv.Atoi(srv.Path[0]); err == nil {
opts.DB = db
}
}
if err := mapstructure.Decode(srv.Args, opts); err != nil {
return nil, err
}
return redis.NewUniversalClient(opts), nil
}

59
redis/container_test.go Normal file
View file

@ -0,0 +1,59 @@
package redis_test
import (
"context"
"fmt"
"net/url"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/baez90/nurse/config"
)
func PrepareRedisContainer(tb testing.TB) *config.Server {
tb.Helper()
const redisPort = "6379/tcp"
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
tb.Cleanup(cancel)
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "docker.io/redis:alpine",
Name: tb.Name(),
ExposedPorts: []string{redisPort},
WaitingFor: wait.ForListeningPort(redisPort),
},
Started: true,
Logger: testcontainers.TestLogger(tb),
})
if err != nil {
tb.Fatalf("testcontainers.GenericContainer() err = %v", err)
}
tb.Cleanup(func() {
if err := container.Terminate(context.Background()); err != nil {
tb.Errorf("container.Terminate() err = %v", err)
}
})
ep, err := container.PortEndpoint(ctx, redisPort, "redis")
if err != nil {
tb.Fatalf("container.PortEndpoint() err = %v", err)
}
u, err := url.Parse(fmt.Sprintf("%s/0?MaxRetries=3", ep))
if err != nil {
tb.Fatalf("url.Parse() err = %v", err)
}
srv, err := config.ParseFromURL(u)
if err != nil {
tb.Fatalf("config.ParseFromURL() err = %v", err)
}
return srv
}

50
redis/get.go Normal file
View file

@ -0,0 +1,50 @@
package redis
import (
"context"
"github.com/go-redis/redis/v8"
"github.com/baez90/nurse/check"
"github.com/baez90/nurse/grammar"
)
var (
_ check.SystemChecker = (*GetCheck)(nil)
_ grammar.CheckUnmarshaler = (*GetCheck)(nil)
)
type GetCheck struct {
redis.UniversalClient
validators ValidationChain
Key string
}
func (g GetCheck) Execute(ctx context.Context) error {
cmd := g.Get(ctx, g.Key)
if err := cmd.Err(); err != nil {
return err
}
return g.validators.Validate(cmd)
}
func (g *GetCheck) UnmarshalCheck(c grammar.Check) error {
const serverAndKeyArgsNumber = 2
inst := c.Initiator
if err := grammar.ValidateParameterCount(inst.Params, serverAndKeyArgsNumber); err != nil {
return err
}
var err error
if g.UniversalClient, err = clientFromParam(inst.Params[0]); err != nil {
return err
}
if g.Key, err = inst.Params[1].AsString(); err != nil {
return err
}
return nil
}

70
redis/get_test.go Normal file
View file

@ -0,0 +1,70 @@
package redis_test
import (
"context"
"fmt"
"testing"
redisCli "github.com/go-redis/redis/v8"
"github.com/maxatome/go-testdeep/td"
"github.com/baez90/nurse/config"
"github.com/baez90/nurse/grammar"
"github.com/baez90/nurse/redis"
)
func TestGetCheck_Execute(t *testing.T) {
t.Parallel()
srv := PrepareRedisContainer(t)
if err := config.DefaultLookup.Register(t.Name(), *srv); err != nil {
t.Fatalf("DefaultLookup.Register() err = %v", err)
}
cli, err := redis.ClientForServer(srv)
if err != nil {
t.Fatalf("redis.ClientForServer() err = %v", err)
}
tests := []struct {
name string
check string
setup func(tb testing.TB, cli redisCli.UniversalClient)
wantErr bool
}{
{
name: "Get value",
check: fmt.Sprintf(`redis.GET("%s", "some_key")`, t.Name()),
setup: func(tb testing.TB, cli redisCli.UniversalClient) {
tb.Helper()
td.CmpNoError(tb, cli.Set(context.Background(), "some_key", "some_value", 0).Err())
},
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.setup != nil {
tt.setup(t, cli)
}
get := new(redis.GetCheck)
parser, err := grammar.NewParser[grammar.Check]()
td.CmpNoError(t, err, "grammar.NewParser()")
check, err := parser.Parse(tt.check)
td.CmpNoError(t, err, "parser.Parse()")
td.CmpNoError(t, get.UnmarshalCheck(*check), "get.UnmarshalCheck()")
td.CmpNoError(t, get.Execute(context.Background()))
if tt.wantErr {
td.CmpError(t, get.Execute(context.Background()))
} else {
td.CmpNoError(t, get.Execute(context.Background()))
}
})
}
}

66
redis/ping.go Normal file
View file

@ -0,0 +1,66 @@
package redis
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/baez90/nurse/check"
"github.com/baez90/nurse/grammar"
)
var (
_ check.SystemChecker = (*PingCheck)(nil)
_ grammar.CheckUnmarshaler = (*PingCheck)(nil)
)
type PingCheck struct {
redis.UniversalClient
validators ValidationChain
Message string
}
func (p PingCheck) Execute(ctx context.Context) error {
if p.Message == "" {
return p.Ping(ctx).Err()
}
if resp, err := p.Do(ctx, "PING", p.Message).Text(); err != nil {
return err
} else if resp != p.Message {
return fmt.Errorf("expected value %s got %s", p.Message, resp)
}
return nil
}
func (p *PingCheck) UnmarshalCheck(c grammar.Check) error {
const (
serverOnlyArgCount = 1
serverAndMessageArgCount = 2
)
p.validators = append(p.validators, StringCmdValidator("PONG"))
init := c.Initiator
switch len(init.Params) {
case 0:
return grammar.ErrMissingServer
case serverAndMessageArgCount:
if msg, err := init.Params[1].AsString(); err != nil {
return err
} else {
p.validators = ValidationChain{StringCmdValidator(msg)}
}
fallthrough
case serverOnlyArgCount:
if cli, err := clientFromParam(init.Params[0]); err != nil {
return err
} else {
p.UniversalClient = cli
}
return nil
default:
return grammar.ErrAmbiguousParamCount
}
}

61
redis/ping_test.go Normal file
View file

@ -0,0 +1,61 @@
package redis_test
import (
"context"
"fmt"
"testing"
"github.com/maxatome/go-testdeep/td"
"github.com/baez90/nurse/config"
"github.com/baez90/nurse/grammar"
"github.com/baez90/nurse/redis"
)
func TestPingCheck_Execute(t *testing.T) {
t.Parallel()
srv := PrepareRedisContainer(t)
if err := config.DefaultLookup.Register(t.Name(), *srv); err != nil {
t.Fatalf("DefaultLookup.Register() err = %v", err)
}
tests := []struct {
name string
check string
wantErr bool
}{
{
name: "PING check",
check: fmt.Sprintf(`redis.PING("%s")`, t.Name()),
wantErr: false,
},
// redis.PING("my-redis")
// redis.PING("my-redis", "Hello, Redis!")
// redis.GET("my-redis", "some-key") -> String("Hello")
// redis.PING("my-redis"); redis.GET("my-redis", "some-key") -> String("Hello")
{
name: "PING check - with custom message",
check: fmt.Sprintf(`redis.PING("%s", "Hello, Redis!")`, t.Name()),
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ping := new(redis.PingCheck)
parser, err := grammar.NewParser[grammar.Check]()
td.CmpNoError(t, err, "grammar.NewParser()")
check, err := parser.Parse(tt.check)
td.CmpNoError(t, err, "parser.Parse()")
if err := ping.UnmarshalCheck(*check); err != nil {
t.Fatalf("UnmarshalCheck() err = %v", err)
}
if err := ping.Execute(context.Background()); (err != nil) != tt.wantErr {
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

49
redis/validation.go Normal file
View file

@ -0,0 +1,49 @@
package redis
import (
"errors"
"fmt"
"github.com/go-redis/redis/v8"
)
var (
_ CmdValidator = (ValidationChain)(nil)
_ CmdValidator = StringCmdValidator("")
)
type (
CmdValidator interface {
Validate(cmder redis.Cmder) error
}
ValidationChain []CmdValidator
)
func (v ValidationChain) Validate(cmder redis.Cmder) error {
for i := range v {
if err := v[i].Validate(cmder); err != nil {
return err
}
}
return nil
}
type StringCmdValidator string
func (s StringCmdValidator) Validate(cmder redis.Cmder) error {
if err := cmder.Err(); err != nil {
return err
}
if stringCmd, ok := cmder.(*redis.StringCmd); !ok {
return errors.New("not a string result")
} else if got, err := stringCmd.Result(); err != nil {
return err
} else if want := string(s); got != want {
return fmt.Errorf("want %s but got %s", want, got)
}
return nil
}