chore: cleanup, add docs, refactor some quirks and prepare release
Some checks failed
Renovate / renovate (push) Successful in 1m8s
Go build / build (push) Failing after 2m3s

This commit is contained in:
Peter 2023-12-04 16:59:10 +01:00
parent a51563c53d
commit 0faff1d481
No known key found for this signature in database
18 changed files with 366 additions and 124 deletions

View file

@ -16,19 +16,24 @@ jobs:
fetch-depth: '0' fetch-depth: '0'
lfs: 'true' lfs: 'true'
fetch-tags: 'true' fetch-tags: 'true'
- name: Setup Go 1.21.x - name: Setup Go 1.21.x
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
# Semantic version range syntax or exact version of Go # Semantic version range syntax or exact version of Go
go-version: '1.21.x' go-version: '1.21.x'
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v3
- name: Install Task - name: Install Task
uses: arduino/setup-task@v1 uses: arduino/setup-task@v1
- name: Run tests - name: Run tests
run: | run: |
go install gotest.tools/gotestsum@latest go install gotest.tools/gotestsum@latest
gotestsum --junitfile out/results.xml --format pkgname-and-test-fails -- -race -shuffle=on ./... gotestsum --junitfile out/results.xml --format pkgname-and-test-fails -- -race -shuffle=on ./...
- uses: goreleaser/goreleaser-action@v5 - uses: goreleaser/goreleaser-action@v5
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
with: with:
@ -36,3 +41,9 @@ jobs:
version: latest version: latest
args: release --clean --snapshot args: release --clean --snapshot
- uses: goreleaser/goreleaser-action@v5
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
with:
distribution: goreleaser
version: latest
args: release --clean

View file

@ -1,6 +1,3 @@
before:
hooks:
- go mod tidy
builds: builds:
- id: nurse - id: nurse
binary: nurse binary: nurse
@ -26,6 +23,16 @@ changelog:
- '^docs:' - '^docs:'
- '^test:' - '^test:'
release:
gitea:
owner: prskr
repo: nurse
ids:
- nurse
mode: replace
extra_files:
- glob: ./nurse.yaml
dockers: dockers:
- ids: - ids:
- nurse - nurse

View file

@ -1,3 +1,89 @@
# Nurse # Nurse
A generic service health sidecar ## Usage
Nurse comes currently with 2 different operation modes:
- server
- CLI
The server starts an HTTP server with configurable endpoints which you can use e.g. in Kubernetes environments to
distinguish between:
- startup
- readiness
- liveness
probes.
Every endpoint has a distinguished set of checks that are executed when you hit the endpoint.
Currently, there is no caching in place (and there are also no plans to change that).
The CLI operation mode on the other hand executes all checks that are provided as arguments e.g. in Docker Swarm
environment where the container image has to ship the health check CLI.
### Primer about checks
All checks are executed **in parallel** which means you shouldn't rely on a certain execution
### Global config/options
Nurse comes with the following global options:
| Switch | Environment variable | Default value | Description |
|--------------------|------------------------|--------------------------------------------------------------|---------------------------------------------------------------------|
| `--config` | `NURSE_CONFIG` | `$HOME/.nurse.yaml`, `/etc/nurse/config.yaml`,`./nurse.yaml` | path to the config file |
| `--check-timeout` | `NURSE_CHECK_TIMEOUT` | `500ms` | Timeout for executing all checks |
| `--check-attempts` | `NURSE_CHECK_ATTEMPTS` | `20` | How often checks should be retried before they're considered failed |
| `--log.level` | | `info` | Default log level |
| `--servers` | `NURSE_SERVER_<name>` | | Configure server URLs via environment variables |
The individual sub-commands come with additional options, like for example configuring endpoints via environment
variables as well.
The [nurse.yaml](./nurse.yaml) describes how to configure Nurse via a configuration file.
The most interesting root nodes are:
- servers
- endpoints
Within `servers` you can configure different servers for further usage in checks.
For example, to configure a Redis server: `redis://localhost:6379/0`.
Depending on the individual protocols there are further configuration options.
Within `endpoints` you can configure different HTTP endpoints the server exposes and which checks should be executed for
which endpoint.
### Server
The `server` sub-command comes with the following additional config options:
| Switch | Environment variable | Default value | Description |
|-----------------------------|----------------------------------|---------------|------------------------------------------------------------|
| `--endpoints` | `NURSE_ENDPOINT_<name>` | | Configure HTTP endpoints via environment variables |
| `--http.address` | `NURSE_HTTP_ADDRESS` | `:8080` | IP and port the server will be listening on |
| `--http.read-header-timout` | `NURSE_HTTP_READ_HEADER_TIMEOUT` | `100ms` | Timeout until when the client has to have sent the headers |
To configure an endpoint via an environment variable, set it like this:
```
NURSE_ENDPOINT_HEALTHZ='http.GET("https://api.chucknorris.io/jokes/random")=>Status(200);redis.PING("local-redis")'
```
The server will print the configured routes when it is starting up.
In the aforementioned case you should see something like:
```
{"time":"xxxxx","level":"INFO","msg":"Configuring route","route":"/healthz"}
```
Multiple checks can be configured by separating them with a `;` into multiple 'expressions'.
### CLI
The CLI has no additional config options compared to the server.
It simply takes all arguments you pass to it, tries to parse them as checks and executes them with the given time limit.
If one of the check fails it will exit with a non-zero exit code.
Multiple checks can either be passed as single argument in `''` separated with a `;` just like in the environment variables, or you can pass multiple arguments.
The result will be the same.

View file

@ -3,6 +3,7 @@ package check
import ( import (
"context" "context"
"errors" "errors"
"time"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar" "code.icb4dc0.de/prskr/nurse/grammar"
@ -21,7 +22,8 @@ type (
Context interface { Context interface {
context.Context context.Context
AttemptContext() (context.Context, context.CancelFunc) AttemptCount() uint
AttemptTimeout() time.Duration
WithParent(ctx context.Context) Context WithParent(ctx context.Context) Context
} }

View file

@ -14,21 +14,27 @@ func AttemptsContext(parent context.Context, numberOfAttempts uint, attemptTimeo
return &checkContext{ return &checkContext{
Context: base, Context: base,
attemptTimeout: attemptTimeout, attemptTimeout: attemptTimeout,
numberOfAttempts: numberOfAttempts,
}, cancel }, cancel
} }
type checkContext struct { type checkContext struct {
attemptTimeout time.Duration attemptTimeout time.Duration
numberOfAttempts uint
context.Context context.Context
} }
func (c *checkContext) AttemptCount() uint {
return c.numberOfAttempts
}
func (c *checkContext) AttemptTimeout() time.Duration {
return c.attemptTimeout
}
func (c *checkContext) WithParent(ctx context.Context) Context { func (c *checkContext) WithParent(ctx context.Context) Context {
return &checkContext{ return &checkContext{
Context: ctx, Context: ctx,
attemptTimeout: c.attemptTimeout, attemptTimeout: c.attemptTimeout,
} }
} }
func (c *checkContext) AttemptContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(c, c.attemptTimeout)
}

View file

@ -6,12 +6,13 @@ import (
"os" "os"
"time" "time"
"github.com/urfave/cli/v2"
"code.icb4dc0.de/prskr/nurse/check" "code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/protocols/http" "code.icb4dc0.de/prskr/nurse/protocols/http"
"code.icb4dc0.de/prskr/nurse/protocols/redis" "code.icb4dc0.de/prskr/nurse/protocols/redis"
"code.icb4dc0.de/prskr/nurse/protocols/sql" "code.icb4dc0.de/prskr/nurse/protocols/sql"
"github.com/urfave/cli/v2"
) )
const ( const (
@ -19,6 +20,16 @@ const (
defaultAttemptCount = 20 defaultAttemptCount = 20
) )
const (
logLevelFlag = "log.level"
httpAddressFlag = "http.address"
httpReadHeaderTimeout = "http.read-header-timeout"
maxCheckAttemptsFlag = "check-attempts"
checkTimeoutFlag = "check-timeout"
serversFlag = "servers"
configFlag = "config"
)
func NewApp() (*cli.App, error) { func NewApp() (*cli.App, error) {
app := &app{ app := &app{
registry: check.NewRegistry(), registry: check.NewRegistry(),
@ -46,30 +57,30 @@ func NewApp() (*cli.App, error) {
Before: app.init, Before: app.init,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "config", Name: configFlag,
Usage: "Config file to load, if not set `$HOME/.nurse.yaml`, `/etc/nurse/config.yaml` and `./nurse.yaml` are tried - optional", Usage: "Config file to load, if not set `$HOME/.nurse.yaml`, `/etc/nurse/config.yaml` and `./nurse.yaml` are tried - optional",
Aliases: []string{"c"}, Aliases: []string{"c"},
EnvVars: []string{"NURSE_CONFIG"}, EnvVars: []string{"NURSE_CONFIG"},
}, },
&cli.DurationFlag{ &cli.DurationFlag{
Name: "check-timeout", Name: checkTimeoutFlag,
Usage: "Timeout when running checks", Usage: "Timeout when running checks",
Value: defaultCheckTimeout, Value: defaultCheckTimeout,
EnvVars: []string{"NURSE_CHECK_TIMEOUT"}, EnvVars: []string{"NURSE_CHECK_TIMEOUT"},
}, },
&cli.UintFlag{ &cli.UintFlag{
Name: "check-attempts", Name: maxCheckAttemptsFlag,
Usage: "Number of attempts for a check", Usage: "Number of attempts for a check",
Value: defaultAttemptCount, Value: defaultAttemptCount,
EnvVars: []string{"NURSE_CHECK_ATTEMPTS"}, EnvVars: []string{"NURSE_CHECK_ATTEMPTS"},
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "log-level", Name: logLevelFlag,
Usage: "Log level to use", Usage: "Log level to use",
Value: "info", Value: "info",
}, },
&cli.StringSliceFlag{ &cli.StringSliceFlag{
Name: "servers", Name: serversFlag,
Usage: "", Usage: "",
Aliases: []string{"s"}, Aliases: []string{"s"},
}, },
@ -83,9 +94,21 @@ func NewApp() (*cli.App, error) {
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringSliceFlag{ &cli.StringSliceFlag{
Name: "endpoints", Name: "endpoints",
Usage: "", Usage: "Endpoints to expose in the HTTP server",
Aliases: []string{"ep"}, Aliases: []string{"ep"},
}, },
&cli.StringFlag{
Name: httpAddressFlag,
Usage: "HTTP server address",
Value: ":8080",
EnvVars: []string{"NURSE_HTTP_ADDRESS"},
},
&cli.DurationFlag{
Name: httpReadHeaderTimeout,
Usage: "Timeout for reading headers in the HTTP server",
Value: 100 * time.Millisecond,
EnvVars: []string{"NURSE_HTTP_READ_HEADER_TIMEOUT"},
},
}, },
}, },
{ {
@ -109,16 +132,16 @@ type app struct {
} }
func (a *app) init(ctx *cli.Context) (err error) { func (a *app) init(ctx *cli.Context) (err error) {
if err = a.configureLogging(ctx.String("log-level")); err != nil { if err = a.configureLogging(ctx.String(logLevelFlag)); err != nil {
return err return err
} }
a.nurseInstance, err = config.New( a.nurseInstance, err = config.New(
config.WithCheckAttempts(ctx.Uint("check-attempts")), config.WithCheckAttempts(ctx.Uint(maxCheckAttemptsFlag)),
config.WithCheckDuration(ctx.Duration("check-timeout")), config.WithCheckDuration(ctx.Duration(checkTimeoutFlag)),
config.WithConfigFile(ctx.String("config")), config.WithConfigFile(ctx.String(configFlag)),
config.WithServersFromEnv(), config.WithServersFromEnv(),
config.WithServersFromArgs(ctx.StringSlice("servers")), config.WithServersFromArgs(ctx.StringSlice(serversFlag)),
config.WithEndpointsFromEnv(), config.WithEndpointsFromEnv(),
) )

View file

@ -1,13 +1,15 @@
package cmd package cmd
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"time"
"github.com/urfave/cli/v2"
"code.icb4dc0.de/prskr/nurse/api" "code.icb4dc0.de/prskr/nurse/api"
"github.com/urfave/cli/v2"
) )
type server struct { type server struct {
@ -23,9 +25,12 @@ func (a *server) RunServer(ctx *cli.Context) error {
} }
srv := http.Server{ srv := http.Server{
Addr: ":8080", Addr: ctx.String(httpAddressFlag),
ReadHeaderTimeout: ctx.Duration(httpReadHeaderTimeout),
BaseContext: func(listener net.Listener) context.Context {
return ctx.Context
},
Handler: mux, Handler: mux,
ReadHeaderTimeout: 100 * time.Millisecond,
} }
if err := srv.ListenAndServe(); err != nil { if err := srv.ListenAndServe(); err != nil {

2
go.mod
View file

@ -17,7 +17,6 @@ require (
github.com/testcontainers/testcontainers-go v0.26.0 github.com/testcontainers/testcontainers-go v0.26.0
github.com/urfave/cli/v2 v2.26.0 github.com/urfave/cli/v2 v2.26.0
github.com/valyala/bytebufferpool v1.0.0 github.com/valyala/bytebufferpool v1.0.0
golang.org/x/exp v0.0.0-20231127185646-65229373498e
golang.org/x/sync v0.5.0 golang.org/x/sync v0.5.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -70,6 +69,7 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/crypto v0.16.0 // indirect golang.org/x/crypto v0.16.0 // indirect
golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/sys v0.15.0 // indirect

2
go.sum
View file

@ -172,8 +172,6 @@ github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0h
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI=
github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=

42
internal/retry/retry.go Normal file
View file

@ -0,0 +1,42 @@
package retry
import (
"context"
"errors"
"time"
)
// Retry executes a function with the given number of attempts and attempt timeouts.
// It returns the last error encountered during the attempts.
// If the context is canceled, it returns the context error (if there is no previous error),
// or the joined error of the last error and the context error (otherwise).
func Retry(ctx context.Context, numberOfAttempts uint, attemptTimeout time.Duration, f func(ctx context.Context, attempt int) error) (lastErr error) {
baseCtx, baseCancel := context.WithTimeout(ctx, time.Duration(numberOfAttempts)*attemptTimeout)
defer baseCancel()
for i := uint(0); i < numberOfAttempts; i++ {
select {
case <-ctx.Done():
if lastErr == nil {
return ctx.Err()
}
return errors.Join(lastErr, ctx.Err())
default:
attemptCtx, attemptCancel := context.WithTimeout(baseCtx, attemptTimeout)
lastErr = f(attemptCtx, int(i))
if lastErr == nil {
attemptCancel()
return nil
}
if attemptCtx.Err() == nil {
<-attemptCtx.Done()
}
attemptCancel()
}
}
return lastErr
}

View file

@ -2,6 +2,7 @@ package http
import ( import (
"bytes" "bytes"
"context"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@ -9,6 +10,7 @@ import (
"code.icb4dc0.de/prskr/nurse/check" "code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar" "code.icb4dc0.de/prskr/nurse/grammar"
"code.icb4dc0.de/prskr/nurse/internal/retry"
"code.icb4dc0.de/prskr/nurse/validation" "code.icb4dc0.de/prskr/nurse/validation"
) )
@ -38,12 +40,15 @@ func (g *GenericCheck) SetClient(client *http.Client) {
} }
func (g *GenericCheck) Execute(ctx check.Context) error { func (g *GenericCheck) Execute(ctx check.Context) error {
slog.Default().Debug("Execute check", logger := slog.Default().With(
slog.String("check", "http"), slog.String("check", "http"),
slog.String("method", g.Method), slog.String("method", g.Method),
slog.String("url", g.URL), slog.String("url", g.URL),
) )
return retry.Retry(ctx, ctx.AttemptCount(), ctx.AttemptTimeout(), func(ctx context.Context, attempt int) error {
logger.Debug("Execute check", slog.Int("attempt", attempt))
var body io.Reader var body io.Reader
if len(g.Body) > 0 { if len(g.Body) > 0 {
body = bytes.NewReader(g.Body) body = bytes.NewReader(g.Body)
@ -63,6 +68,7 @@ func (g *GenericCheck) Execute(ctx check.Context) error {
}() }()
return g.validators.Validate(resp) return g.validators.Validate(resp)
})
} }
func (g *GenericCheck) UnmarshalCheck(c grammar.Check, _ config.ServerLookup) error { func (g *GenericCheck) UnmarshalCheck(c grammar.Check, _ config.ServerLookup) error {

View file

@ -13,6 +13,9 @@ func Module() *check.Module {
check.WithCheck("get", check.FactoryFunc(func() check.SystemChecker { check.WithCheck("get", check.FactoryFunc(func() check.SystemChecker {
return new(GetCheck) return new(GetCheck)
})), })),
check.WithCheck("set", check.FactoryFunc(func() check.SystemChecker {
return new(SetCheck)
})),
) )
if err != nil { if err != nil {
panic(err) panic(err)

View file

@ -56,6 +56,11 @@ func TestChecks_Execute(t *testing.T) {
check: `redis.PING("%s", "Hello, Redis!")`, check: `redis.PING("%s", "Hello, Redis!")`,
wantErr: false, wantErr: false,
}, },
{
name: "SET check",
check: `redis.SET("%s", "Hello", "World!")`,
wantErr: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt

View file

@ -6,6 +6,8 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"code.icb4dc0.de/prskr/nurse/internal/retry"
"code.icb4dc0.de/prskr/nurse/check" "code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar" "code.icb4dc0.de/prskr/nurse/grammar"
@ -21,27 +23,14 @@ type GetCheck struct {
} }
func (g *GetCheck) Execute(ctx check.Context) error { func (g *GetCheck) Execute(ctx check.Context) error {
slog.Default().Debug("Execute check", logger := slog.Default().With(
slog.String("check", "redis.GET"), slog.String("check", "redis.GET"),
slog.String("key", g.Key), slog.String("key", g.Key),
) )
for { return retry.Retry(ctx, ctx.AttemptCount(), ctx.AttemptTimeout(), func(ctx context.Context, attempt int) error {
select { logger.Debug("Execute check", slog.Int("attempt", attempt))
case <-ctx.Done():
return ctx.Err()
default:
attemptCtx, cancel := ctx.AttemptContext()
err := g.executeAttempt(attemptCtx)
cancel()
if err == nil {
return nil
}
}
}
}
func (g *GetCheck) executeAttempt(ctx context.Context) error {
cmd := g.Get(ctx, g.Key) cmd := g.Get(ctx, g.Key)
if err := cmd.Err(); err != nil { if err := cmd.Err(); err != nil {
@ -49,6 +38,7 @@ func (g *GetCheck) executeAttempt(ctx context.Context) error {
} }
return g.validators.Validate(cmd) return g.validators.Validate(cmd)
})
} }
func (g *GetCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) error { func (g *GetCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) error {

View file

@ -10,6 +10,7 @@ import (
"code.icb4dc0.de/prskr/nurse/check" "code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar" "code.icb4dc0.de/prskr/nurse/grammar"
"code.icb4dc0.de/prskr/nurse/internal/retry"
"code.icb4dc0.de/prskr/nurse/validation" "code.icb4dc0.de/prskr/nurse/validation"
) )
@ -21,32 +22,19 @@ type PingCheck struct {
Message string Message string
} }
func (p PingCheck) Execute(ctx check.Context) error { func (p *PingCheck) Execute(ctx check.Context) error {
slog.Default().Debug("Execute check", logger := slog.Default().With(
slog.String("check", "redis.PING"), slog.String("check", "redis.PING"),
slog.String("msg", p.Message), slog.String("msg", p.Message),
) )
return retry.Retry(ctx, ctx.AttemptCount(), ctx.AttemptTimeout(), func(ctx context.Context, attempt int) error {
logger.Debug("Execute check", slog.Int("attempt", attempt))
if p.Message == "" { if p.Message == "" {
return p.Ping(ctx).Err() return p.Ping(ctx).Err()
} }
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
attemptCtx, cancel := ctx.AttemptContext()
err := p.executeAttempt(attemptCtx)
cancel()
if err == nil {
return nil
}
}
}
}
func (p PingCheck) executeAttempt(ctx context.Context) error {
if resp, err := p.Do(ctx, "PING", p.Message).Text(); err != nil { if resp, err := p.Do(ctx, "PING", p.Message).Text(); err != nil {
return err return err
} else if resp != p.Message { } else if resp != p.Message {
@ -54,6 +42,7 @@ func (p PingCheck) executeAttempt(ctx context.Context) error {
} }
return nil return nil
})
} }
func (p *PingCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) error { func (p *PingCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) error {

69
protocols/redis/set.go Normal file
View file

@ -0,0 +1,69 @@
package redis
import (
"context"
"log/slog"
"github.com/redis/go-redis/v9"
"code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar"
"code.icb4dc0.de/prskr/nurse/internal/retry"
"code.icb4dc0.de/prskr/nurse/validation"
)
var _ check.SystemChecker = (*SetCheck)(nil)
type SetCheck struct {
redis.UniversalClient
validators validation.Validator[redis.Cmder]
Key, Value string
}
func (s *SetCheck) Execute(ctx check.Context) error {
logger := slog.Default().With(
slog.String("check", "redis.SET"),
slog.String("key", s.Key),
slog.String("value", s.Value),
)
return retry.Retry(ctx, ctx.AttemptCount(), ctx.AttemptTimeout(), func(ctx context.Context, attempt int) error {
logger.Debug("Execute check", slog.Int("attempt", attempt))
cmd := s.Set(ctx, s.Key, s.Value, -1)
if err := cmd.Err(); err != nil {
return err
}
return s.validators.Validate(cmd)
})
}
func (s *SetCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) error {
const serverKeyAndValueArgsNumber = 3
inst := c.Initiator
if err := grammar.ValidateParameterCount(inst.Params, serverKeyAndValueArgsNumber); err != nil {
return err
}
var err error
if s.UniversalClient, err = clientFromParam(inst.Params[0], lookup); err != nil {
return err
}
if s.Key, err = inst.Params[1].AsString(); err != nil {
return err
}
if s.Value, err = inst.Params[2].AsString(); err != nil {
return err
}
if s.validators, err = registry.ValidatorsForFilters(c.Validators); err != nil {
return err
}
return nil
}

View file

@ -75,12 +75,22 @@ func (g *GenericCmdValidator) Validate(cmder redis.Cmder) error {
return err return err
} }
if in, ok := cmder.(*redis.StringCmd); ok { switch in := cmder.(type) {
case *redis.StringCmd:
if err := in.Err(); err != nil {
return err
}
res, err := in.Result() res, err := in.Result()
if err != nil { if err != nil {
return err return err
} }
return g.comparator.Equals(res) return g.comparator.Equals(res)
case *redis.StatusCmd:
if err := in.Err(); err != nil {
return err
}
} }
return nil return nil

View file

@ -9,6 +9,7 @@ import (
"code.icb4dc0.de/prskr/nurse/check" "code.icb4dc0.de/prskr/nurse/check"
"code.icb4dc0.de/prskr/nurse/config" "code.icb4dc0.de/prskr/nurse/config"
"code.icb4dc0.de/prskr/nurse/grammar" "code.icb4dc0.de/prskr/nurse/grammar"
"code.icb4dc0.de/prskr/nurse/internal/retry"
"code.icb4dc0.de/prskr/nurse/validation" "code.icb4dc0.de/prskr/nurse/validation"
) )
@ -44,29 +45,17 @@ func (s *SelectCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup
} }
func (s *SelectCheck) Execute(ctx check.Context) error { func (s *SelectCheck) Execute(ctx check.Context) error {
slog.Default().Debug("Execute check", logger := slog.Default().With(
slog.String("check", "sql.SELECT"), slog.String("check", "sql.SELECT"),
slog.String("query", s.Query), slog.String("query", s.Query),
) )
for { return retry.Retry(ctx, ctx.AttemptCount(), ctx.AttemptTimeout(), func(ctx context.Context, attempt int) error {
select { logger.Debug("Execute check", slog.Int("attempt", attempt))
case <-ctx.Done():
return ctx.Err()
default:
attemptCtx, cancel := ctx.AttemptContext()
err := s.executeAttempt(attemptCtx)
cancel()
if err == nil {
return nil
}
}
}
}
func (s *SelectCheck) executeAttempt(ctx context.Context) (err error) { logger.Debug("")
var rows *sql.Rows
rows, err = s.QueryContext(ctx, s.Query) rows, err := s.QueryContext(ctx, s.Query)
if err != nil { if err != nil {
return err return err
} }
@ -76,4 +65,5 @@ func (s *SelectCheck) executeAttempt(ctx context.Context) (err error) {
}() }()
return s.validators.Validate(rows) return s.validators.Validate(rows)
})
} }