Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
run: |
mkdir -p coverage-ci
cd tests
go test -timeout 20m -v -race -cover -tags=debug -failfast -coverpkg=github.com/roadrunner-server/status/v6/... -coverprofile=../coverage-ci/e2e.out -covermode=atomic ./...
go test -timeout 20m -v -race -cover -coverpkg=github.com/roadrunner-server/status/v6/... -coverprofile=../coverage-ci/e2e.out -covermode=atomic ./...

- name: Archive code coverage results
uses: actions/upload-artifact@v7
Expand Down
44 changes: 44 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package status

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
)

func TestConfigInitDefaults(t *testing.T) {
for _, tt := range []struct {
name string
cfg Config
wantCode int
wantTimeoutS int
}{
{
name: "zero value",
cfg: Config{},
wantCode: http.StatusServiceUnavailable,
wantTimeoutS: 60,
},
{
name: "negative check timeout",
cfg: Config{CheckTimeout: -1},
wantCode: http.StatusServiceUnavailable,
wantTimeoutS: 60,
},
{
name: "configured values are kept",
cfg: Config{CheckTimeout: 5, UnavailableStatusCode: http.StatusInternalServerError},
wantCode: http.StatusInternalServerError,
wantTimeoutS: 5,
},
} {
t.Run(tt.name, func(t *testing.T) {
cfg := tt.cfg
cfg.InitDefaults()

assert.Equal(t, tt.wantCode, cfg.UnavailableStatusCode)
assert.Equal(t, tt.wantTimeoutS, cfg.CheckTimeout)
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26
toolchain go1.26.5

require (
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14
github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2
github.com/roadrunner-server/endure/v2 v2.6.2
github.com/roadrunner-server/errors v1.5.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13 h1:BAV1aKkRp51C1OXDfEYZXgfrXqn4O7bpr6Z/m5otwd8=
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI=
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 h1:sTskv/3ImOZlUdtHuj9uT24gm1gQl/qU8rFNvn3MzhU=
github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI=
github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 h1:GqsZzWQ5jMXRF1O/b8IqFz9PLpS7Ui0K4OyACLql2MI=
github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2/go.mod h1:2v4yUK5Kvbvq8C3IkDoBkuamq9h+7i/JLjyf7k1j5JM=
github.com/roadrunner-server/endure/v2 v2.6.2 h1:sIB4kTyE7gtT3fDhuYWUYn6Vt/dcPtiA6FoNS1eS+84=
Expand Down
134 changes: 134 additions & 0 deletions handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,38 @@ func (m *mockJobsChecker) JobsState(_ context.Context) ([]*jobsApi.State, error)
}
func (m *mockJobsChecker) Name() string { return "jobs" }

// failingWriter is a ResponseWriter whose body write always fails, which is how
// a client that hangs up mid-response looks to a handler.
type failingWriter struct {
header http.Header
}

func newFailingWriter() *failingWriter {
return &failingWriter{header: make(http.Header)}
}

func (w *failingWriter) Header() http.Header { return w.header }
func (w *failingWriter) WriteHeader(int) {}

func (w *failingWriter) Write([]byte) (int, error) {
return 0, errors.New("broken pipe")
}

// logCapture keeps the messages the handler logged.
type logCapture struct {
messages []string
}

func (c *logCapture) Enabled(context.Context, slog.Level) bool { return true }

func (c *logCapture) Handle(_ context.Context, r slog.Record) error {
c.messages = append(c.messages, r.Message)
return nil
}

func (c *logCapture) WithAttrs([]slog.Attr) slog.Handler { return c }
func (c *logCapture) WithGroup(string) slog.Handler { return c }

// --- Helpers ---

func newShutdownPtr(val bool) *atomic.Bool {
Expand Down Expand Up @@ -228,6 +260,21 @@ func TestHealthHandler(t *testing.T) {
assert.Empty(t, reports)
})

t.Run("Filtered_NilPlugin", func(t *testing.T) {
registry := map[string]Checker{
"http": nil,
}
h := NewHealthHandler(registry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/health?plugin=http", nil)
h.ServeHTTP(rec, req)

// Filtered path skips a nil plugin, while the all-plugins path reports it
assert.Equal(t, http.StatusOK, rec.Code)
reports := parseReports(t, rec.Body.Bytes())
assert.Empty(t, reports)
})

t.Run("Filtered_Error", func(t *testing.T) {
registry := map[string]Checker{
"http": &mockChecker{name: "http", err: errors.New("connection refused")},
Expand Down Expand Up @@ -470,6 +517,21 @@ func TestReadyHandler(t *testing.T) {
assert.Empty(t, reports)
})

t.Run("Filtered_NilPlugin", func(t *testing.T) {
registry := map[string]Readiness{
"http": nil,
}
h := NewReadyHandler(registry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ready?plugin=http", nil)
h.ServeHTTP(rec, req)

// Filtered path skips a nil plugin, while the all-plugins path reports it
assert.Equal(t, http.StatusOK, rec.Code)
reports := parseReports(t, rec.Body.Bytes())
assert.Empty(t, reports)
})

t.Run("Filtered_Error", func(t *testing.T) {
registry := map[string]Readiness{
"http": &mockReadiness{name: "http", err: errors.New("not ready")},
Expand Down Expand Up @@ -631,6 +693,78 @@ func TestJobsHandler(t *testing.T) {
})
}

// --- Response Write Failures ---

// TestHandlerWriteError checks that a handler whose response body cannot be
// written logs the failure instead of panicking.
func TestHandlerWriteError(t *testing.T) {
healthRegistry := map[string]Checker{
"http": &mockChecker{name: "http", st: &apiStatus.Status{Code: 200}},
}
readyRegistry := map[string]Readiness{
"http": &mockReadiness{name: "http", st: &apiStatus.Status{Code: 200}},
}

for _, tt := range []struct {
newHandler func(log *slog.Logger) http.Handler
name string
target string
wantLog string
}{
{
name: "HealthAllPlugins",
target: "/health",
newHandler: func(log *slog.Logger) http.Handler {
return NewHealthHandler(healthRegistry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
},
wantLog: "failed to write response",
},
{
name: "HealthFiltered",
target: "/health?plugin=http",
newHandler: func(log *slog.Logger) http.Handler {
return NewHealthHandler(healthRegistry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
},
wantLog: "failed to write response",
},
{
name: "ReadyAllPlugins",
target: "/ready",
newHandler: func(log *slog.Logger) http.Handler {
return NewReadyHandler(readyRegistry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
},
wantLog: "failed to write response",
},
{
name: "ReadyFiltered",
target: "/ready?plugin=http",
newHandler: func(log *slog.Logger) http.Handler {
return NewReadyHandler(readyRegistry, newShutdownPtr(false), log, http.StatusServiceUnavailable)
},
wantLog: "failed to write response",
},
{
name: "Jobs",
target: "/jobs",
newHandler: func(log *slog.Logger) http.Handler {
jc := &mockJobsChecker{states: []*jobsApi.State{{Pipeline: "pipe1", Driver: "memory"}}}
return NewJobsHandler(jc, newShutdownPtr(false), log, http.StatusServiceUnavailable)
},
wantLog: "failed to write jobs state report",
},
} {
t.Run(tt.name, func(t *testing.T) {
capture := &logCapture{}
w := newFailingWriter()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tt.target, nil)

tt.newHandler(slog.New(capture)).ServeHTTP(w, req)

assert.Contains(t, capture.messages, tt.wantLog)
})
}
}

// --- Fuzz Tests ---

func FuzzHealthPluginQuery(f *testing.F) {
Expand Down
69 changes: 64 additions & 5 deletions plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,45 @@ package status
import (
stderr "errors"
"log/slog"
"net"
"testing"
"time"

"github.com/roadrunner-server/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// initConfigurer is a minimal Configurer for exercising Plugin.Init's early
// error returns without standing up a full container.
// initConfigurer is a minimal Configurer for exercising Plugin.Init without
// standing up a full container.
type initConfigurer struct {
has bool
cfg *Config
unmarshalErr error
has bool
}

func (c *initConfigurer) Has(string) bool { return c.has }
func (c *initConfigurer) UnmarshalKey(string, any) error { return c.unmarshalErr }
func (c *initConfigurer) Has(string) bool { return c.has }

func (c *initConfigurer) UnmarshalKey(_ string, out any) error {
if c.unmarshalErr != nil {
return c.unmarshalErr
}

// the plugin passes a **Config, which the config plugin fills in
dst, ok := out.(**Config)
if !ok {
return stderr.New("unexpected destination type")
}

cfg := c.cfg
if cfg == nil {
cfg = &Config{}
}

*dst = cfg

return nil
}

type initLogger struct{}

Expand All @@ -37,3 +60,39 @@ func TestPluginInit(t *testing.T) {
assert.True(t, errors.Is(errors.Disabled, err))
})
}

// TestPluginServeAddressTaken checks that a listener the plugin cannot open is
// reported on the channel Serve returns.
func TestPluginServeAddressTaken(t *testing.T) {
var lc net.ListenConfig

ln, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })

p := &Plugin{}
require.NoError(t, p.Init(&initConfigurer{has: true, cfg: &Config{Address: ln.Addr().String()}}, initLogger{}))

errCh := p.Serve()
t.Cleanup(p.StopHTTPServer)

select {
case serveErr := <-errCh:
require.Error(t, serveErr)
case <-time.After(time.Second * 10):
t.Fatal("the address is taken, but the plugin reported no error")
}
}

// TestPluginUnknownPlugin pins the sentinel both lookups wrap, which the rpc
// service turns into the "no such plugin" reply.
func TestPluginUnknownPlugin(t *testing.T) {
p := &Plugin{}
require.NoError(t, p.Init(&initConfigurer{has: true}, initLogger{}))

_, err := p.status("nonexistent")
require.ErrorIs(t, err, errPluginNotFound)

_, err = p.ready("nonexistent")
require.ErrorIs(t, err, errPluginNotFound)
}
6 changes: 3 additions & 3 deletions rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package status
import (
"log/slog"

statusV2 "github.com/roadrunner-server/api-go/v6/status/v2"
statusV1 "github.com/roadrunner-server/api-go/v6/status/v1"
"github.com/roadrunner-server/errors"
)

Expand All @@ -13,7 +13,7 @@ type rpc struct {
}

// Status returns the current status of the provided plugin.
func (r *rpc) Status(in *statusV2.StatusRequest, out *statusV2.StatusResponse) error {
func (r *rpc) Status(in *statusV1.Request, out *statusV1.Response) error {
const op = errors.Op("checker_rpc_status")
plugin := in.GetPlugin()
r.log.Debug("Status method was invoked", "plugin", plugin)
Expand All @@ -33,7 +33,7 @@ func (r *rpc) Status(in *statusV2.StatusRequest, out *statusV2.StatusResponse) e
}

// Ready returns the readiness check of the provided plugin.
func (r *rpc) Ready(in *statusV2.StatusRequest, out *statusV2.StatusResponse) error {
func (r *rpc) Ready(in *statusV1.Request, out *statusV1.Response) error {
const op = errors.Op("checker_rpc_ready")
plugin := in.GetPlugin()
r.log.Debug("Ready method was invoked", "plugin", plugin)
Expand Down
1 change: 0 additions & 1 deletion tests/configs/.rr-ready-init.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ http:
max_request_size: 1024
uploads:
forbid: [ ".php", ".exe", ".bat" ]
trusted_subnets: [ "10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10" ]
pool:
num_workers: 1
allocate_timeout: 5s
Expand Down
1 change: 0 additions & 1 deletion tests/configs/.rr-status-init.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ http:
max_request_size: 1024
uploads:
forbid: [ ".php", ".exe", ".bat" ]
trusted_subnets: [ "10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10" ]
pool:
num_workers: 2
allocate_timeout: 60s
Expand Down
8 changes: 4 additions & 4 deletions tests/doc.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package status contains integration tests for the status plugin. The tests
// exercise HTTP endpoints (/health, /ready, /jobs) and RPC methods using a
// full plugin lifecycle managed by the endure framework.
package status
// Package tests contains the integration tests of the status plugin. They
// drive the /health, /ready and /jobs endpoints and the status rpc methods
// through a full plugin lifecycle managed by the endure framework.
package tests
Loading
Loading