feat(pprof): add pprof HTTP server (#807)

- `PPROF_ENABLED=no`
- `PPROF_BLOCK_PROFILE_RATE=0`
- `PPROF_MUTEX_PROFILE_RATE=0`
- `PPROF_HTTP_SERVER_ADDRESS=":6060"`
This commit is contained in:
Quentin McGaw
2022-01-26 17:23:55 -05:00
committed by GitHub
parent 55e609cbf4
commit 9de6428585
26 changed files with 1659 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
package httpserver
// GetAddress obtains the address the HTTP server is listening on.
func (s *Server) GetAddress() (address string) {
<-s.addressSet
return s.address
}

View File

@@ -0,0 +1,43 @@
package httpserver
import (
"regexp"
"time"
gomock "github.com/golang/mock/gomock"
)
func stringPtr(s string) *string { return &s }
func durationPtr(d time.Duration) *time.Duration { return &d }
var _ Logger = (*testLogger)(nil)
type testLogger struct{}
func (t *testLogger) Info(msg string) {}
func (t *testLogger) Warn(msg string) {}
func (t *testLogger) Error(msg string) {}
var _ gomock.Matcher = (*regexMatcher)(nil)
type regexMatcher struct {
regexp *regexp.Regexp
}
func (r *regexMatcher) Matches(x interface{}) bool {
s, ok := x.(string)
if !ok {
return false
}
return r.regexp.MatchString(s)
}
func (r *regexMatcher) String() string {
return "regular expression " + r.regexp.String()
}
func newRegexMatcher(regex string) *regexMatcher {
return &regexMatcher{
regexp: regexp.MustCompile(regex),
}
}

View File

@@ -0,0 +1,9 @@
package httpserver
// Logger is the logger interface accepted by the
// HTTP server.
type Logger interface {
Info(msg string)
Warn(msg string)
Error(msg string)
}

View File

@@ -0,0 +1,70 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/httpserver (interfaces: Logger)
// Package httpserver is a generated GoMock package.
package httpserver
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Error mocks base method.
func (m *MockLogger) Error(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Error", arg0)
}
// Error indicates an expected call of Error.
func (mr *MockLoggerMockRecorder) Error(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0)
}
// Info mocks base method.
func (m *MockLogger) Info(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Info", arg0)
}
// Info indicates an expected call of Info.
func (mr *MockLoggerMockRecorder) Info(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), arg0)
}
// Warn mocks base method.
func (m *MockLogger) Warn(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Warn", arg0)
}
// Warn indicates an expected call of Warn.
func (mr *MockLoggerMockRecorder) Warn(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), arg0)
}

View File

@@ -0,0 +1,66 @@
package httpserver
import (
"context"
"errors"
"net"
"net/http"
)
// Run runs the HTTP server until ctx is canceled.
// The done channel has an error written to when the HTTP server
// is terminated, and can be nil or not nil.
func (s *Server) Run(ctx context.Context, ready chan<- struct{}, done chan<- struct{}) {
server := http.Server{Addr: s.address, Handler: s.handler}
crashed := make(chan struct{})
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
select {
case <-ctx.Done():
case <-crashed:
return
}
s.logger.Warn(s.name + " http server shutting down: " + ctx.Err().Error())
shutdownCtx, cancel := context.WithTimeout(
context.Background(), s.shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
s.logger.Error(s.name + " http server failed shutting down within " +
s.shutdownTimeout.String())
}
}()
listener, err := net.Listen("tcp", s.address)
if err != nil {
close(s.addressSet)
close(crashed) // stop shutdown goroutine
<-shutdownDone
s.logger.Error(err.Error())
close(done)
return
}
s.address = listener.Addr().String()
close(s.addressSet)
// note: no further write so no need to mutex
s.logger.Info(s.name + " http server listening on " + s.address)
close(ready)
err = server.Serve(listener)
if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
// server crashed
close(crashed) // stop shutdown goroutine
} else {
err = nil
}
<-shutdownDone
if err != nil {
s.logger.Error(err.Error())
}
close(done)
}

View File

@@ -0,0 +1,75 @@
package httpserver
import (
"context"
"regexp"
"testing"
"time"
gomock "github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func Test_Server_Run_success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
logger := NewMockLogger(ctrl)
logger.EXPECT().Info(newRegexMatcher("^test http server listening on 127.0.0.1:[1-9][0-9]{0,4}$"))
logger.EXPECT().Warn("test http server shutting down: context canceled")
const shutdownTimeout = 10 * time.Second
server := &Server{
name: "test",
address: "127.0.0.1:0",
addressSet: make(chan struct{}),
logger: logger,
shutdownTimeout: shutdownTimeout,
}
ctx, cancel := context.WithCancel(context.Background())
ready := make(chan struct{})
done := make(chan struct{})
go server.Run(ctx, ready, done)
addressRegex := regexp.MustCompile(`^127.0.0.1:[1-9][0-9]{0,4}$`)
address := server.GetAddress()
assert.Regexp(t, addressRegex, address)
address = server.GetAddress()
assert.Regexp(t, addressRegex, address)
<-ready
cancel()
_, ok := <-done
assert.False(t, ok)
}
func Test_Server_Run_failure(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
logger := NewMockLogger(ctrl)
logger.EXPECT().Error("listen tcp: address -1: invalid port")
server := &Server{
name: "test",
address: "127.0.0.1:-1",
addressSet: make(chan struct{}),
logger: logger,
}
ready := make(chan struct{})
done := make(chan struct{})
go server.Run(context.Background(), ready, done)
select {
case <-ready:
t.Fatal("server should not be ready")
case _, ok := <-done:
assert.False(t, ok)
}
}

View File

@@ -0,0 +1,57 @@
// Package httpserver implements an HTTP server.
package httpserver
import (
"context"
"fmt"
"net/http"
"time"
)
var _ Interface = (*Server)(nil)
// Interface is the HTTP server composite interface.
type Interface interface {
Runner
AddressGetter
}
// Runner is the interface for an HTTP server with a Run method.
type Runner interface {
Run(ctx context.Context, ready chan<- struct{}, done chan<- struct{})
}
// AddressGetter obtains the address the HTTP server is listening on.
type AddressGetter interface {
GetAddress() (address string)
}
// Server is an HTTP server implementation, which uses
// the HTTP handler provided.
type Server struct {
name string
address string
addressSet chan struct{}
handler http.Handler
logger Logger
shutdownTimeout time.Duration
}
// New creates a new HTTP server with the given settings.
// It returns an error if one of the settings is not valid.
func New(settings Settings) (s *Server, err error) {
settings.SetDefaults()
if err = settings.Validate(); err != nil {
return nil, fmt.Errorf("http server settings validation failed: %w", err)
}
return &Server{
name: *settings.Name,
address: settings.Address,
addressSet: make(chan struct{}),
handler: settings.Handler,
logger: settings.Logger,
shutdownTimeout: *settings.ShutdownTimeout,
}, nil
}

View File

@@ -0,0 +1,67 @@
package httpserver
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//go:generate mockgen -destination=logger_mock_test.go -package $GOPACKAGE . Logger
func Test_New(t *testing.T) {
t.Parallel()
someHandler := http.NewServeMux()
someLogger := &testLogger{}
testCases := map[string]struct {
settings Settings
expected *Server
errWrapped error
errMessage string
}{
"empty settings": {
errWrapped: ErrHandlerIsNotSet,
errMessage: "http server settings validation failed: HTTP handler cannot be left unset",
},
"filled settings": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: &Server{
name: "name",
address: ":8001",
handler: someHandler,
logger: someLogger,
shutdownTimeout: time.Second,
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
server, err := New(testCase.settings)
assert.ErrorIs(t, err, testCase.errWrapped)
if testCase.errWrapped != nil {
require.EqualError(t, err, testCase.errMessage)
}
if server != nil {
assert.NotNil(t, server.addressSet)
server.addressSet = nil
}
assert.Equal(t, testCase.expected, server)
})
}
}

View File

@@ -0,0 +1,111 @@
package httpserver
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/qdm12/gluetun/internal/configuration/settings/helpers"
"github.com/qdm12/gotree"
"github.com/qdm12/govalid/address"
)
type Settings struct {
// Name is the server name to use in logs.
// It defaults to the empty string.
Name *string
// Address is the server listening address.
// It defaults to :8000.
Address string
// Handler is the HTTP Handler to use.
// It must be set and cannot be left to nil.
Handler http.Handler
// Logger is the logger to use.
// It must be set and cannot be left to nil.
Logger Logger
// ShutdownTimeout is the shutdown timeout duration
// of the HTTP server. It defaults to 3 seconds.
ShutdownTimeout *time.Duration
}
func (s *Settings) SetDefaults() {
s.Name = helpers.DefaultStringPtr(s.Name, "")
s.Address = helpers.DefaultString(s.Address, ":8000")
const defaultShutdownTimeout = 3 * time.Second
s.ShutdownTimeout = helpers.DefaultDuration(s.ShutdownTimeout, defaultShutdownTimeout)
}
func (s Settings) Copy() Settings {
return Settings{
Name: helpers.CopyStringPtr(s.Name),
Address: s.Address,
Handler: s.Handler,
Logger: s.Logger,
ShutdownTimeout: helpers.CopyDurationPtr(s.ShutdownTimeout),
}
}
func (s *Settings) MergeWith(other Settings) {
s.Name = helpers.MergeWithStringPtr(s.Name, other.Name)
s.Address = helpers.MergeWithString(s.Address, other.Address)
s.Handler = helpers.MergeWithHTTPHandler(s.Handler, other.Handler)
if s.Logger == nil {
s.Logger = other.Logger
}
s.ShutdownTimeout = helpers.MergeWithDuration(s.ShutdownTimeout, other.ShutdownTimeout)
}
func (s *Settings) OverrideWith(other Settings) {
s.Name = helpers.OverrideWithStringPtr(s.Name, other.Name)
s.Address = helpers.OverrideWithString(s.Address, other.Address)
s.Handler = helpers.OverrideWithHTTPHandler(s.Handler, other.Handler)
if other.Logger != nil {
s.Logger = other.Logger
}
s.ShutdownTimeout = helpers.OverrideWithDuration(s.ShutdownTimeout, other.ShutdownTimeout)
}
var (
ErrHandlerIsNotSet = errors.New("HTTP handler cannot be left unset")
ErrLoggerIsNotSet = errors.New("logger cannot be left unset")
ErrShutdownTimeoutTooSmall = errors.New("shutdown timeout is too small")
)
func (s Settings) Validate() (err error) {
uid := os.Getuid()
_, err = address.Validate(s.Address, address.OptionListening(uid))
if err != nil {
return err
}
if s.Handler == nil {
return ErrHandlerIsNotSet
}
if s.Logger == nil {
return ErrLoggerIsNotSet
}
const minShutdownTimeout = 5 * time.Millisecond
if *s.ShutdownTimeout < minShutdownTimeout {
return fmt.Errorf("%w: %s must be at least %s",
ErrShutdownTimeoutTooSmall,
*s.ShutdownTimeout, minShutdownTimeout)
}
return nil
}
func (s Settings) ToLinesNode() (node *gotree.Node) {
node = gotree.New("%s HTTP server settings:", strings.Title(*s.Name))
node.Appendf("Listening address: %s", s.Address)
node.Appendf("Shutdown timeout: %s", *s.ShutdownTimeout)
return node
}
func (s Settings) String() string {
return s.ToLinesNode().String()
}

View File

@@ -0,0 +1,330 @@
package httpserver
import (
"net/http"
"testing"
"time"
"github.com/qdm12/govalid/address"
"github.com/stretchr/testify/assert"
)
func Test_Settings_SetDefaults(t *testing.T) {
t.Parallel()
const defaultTimeout = 3 * time.Second
testCases := map[string]struct {
settings Settings
expected Settings
}{
"empty settings": {
settings: Settings{},
expected: Settings{
Name: stringPtr(""),
Address: ":8000",
ShutdownTimeout: durationPtr(defaultTimeout),
},
},
"filled settings": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
ShutdownTimeout: durationPtr(time.Second),
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.settings.SetDefaults()
assert.Equal(t, testCase.expected, testCase.settings)
})
}
}
func Test_Settings_Copy(t *testing.T) {
t.Parallel()
someHandler := http.NewServeMux()
someLogger := &testLogger{}
testCases := map[string]struct {
settings Settings
expected Settings
}{
"empty settings": {},
"filled settings": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
copied := testCase.settings.Copy()
assert.Equal(t, testCase.expected, copied)
})
}
}
func Test_Settings_MergeWith(t *testing.T) {
t.Parallel()
someHandler := http.NewServeMux()
someLogger := &testLogger{}
testCases := map[string]struct {
settings Settings
other Settings
expected Settings
}{
"merge empty with empty": {},
"merge empty with filled": {
other: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
"merge filled with empty": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.settings.MergeWith(testCase.other)
assert.Equal(t, testCase.expected, testCase.settings)
})
}
}
func Test_Settings_OverrideWith(t *testing.T) {
t.Parallel()
someHandler := http.NewServeMux()
someLogger := &testLogger{}
testCases := map[string]struct {
settings Settings
other Settings
expected Settings
}{
"override empty with empty": {},
"override empty with filled": {
other: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
"override filled with empty": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
expected: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
"override filled with filled": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8001",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
other: Settings{
Name: stringPtr("name2"),
Address: ":8002",
ShutdownTimeout: durationPtr(time.Hour),
},
expected: Settings{
Name: stringPtr("name2"),
Address: ":8002",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Hour),
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.settings.OverrideWith(testCase.other)
assert.Equal(t, testCase.expected, testCase.settings)
})
}
}
func Test_Settings_Validate(t *testing.T) {
t.Parallel()
someHandler := http.NewServeMux()
someLogger := &testLogger{}
testCases := map[string]struct {
settings Settings
errWrapped error
errMessage string
}{
"bad address": {
settings: Settings{
Address: "noport",
},
errWrapped: address.ErrValueNotValid,
errMessage: "value is not valid: address noport: missing port in address",
},
"nil handler": {
settings: Settings{
Address: ":8000",
},
errWrapped: ErrHandlerIsNotSet,
errMessage: ErrHandlerIsNotSet.Error(),
},
"nil logger": {
settings: Settings{
Address: ":8000",
Handler: someHandler,
},
errWrapped: ErrLoggerIsNotSet,
errMessage: ErrLoggerIsNotSet.Error(),
},
"shutdown timeout too small": {
settings: Settings{
Address: ":8000",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Millisecond),
},
errWrapped: ErrShutdownTimeoutTooSmall,
errMessage: "shutdown timeout is too small: 1ms must be at least 5ms",
},
"valid settings": {
settings: Settings{
Address: ":8000",
Handler: someHandler,
Logger: someLogger,
ShutdownTimeout: durationPtr(time.Second),
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
err := testCase.settings.Validate()
assert.ErrorIs(t, err, testCase.errWrapped)
if err != nil {
assert.EqualError(t, err, testCase.errMessage)
}
})
}
}
func Test_Settings_String(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Settings
s string
}{
"all values": {
settings: Settings{
Name: stringPtr("name"),
Address: ":8000",
ShutdownTimeout: durationPtr(time.Second),
},
s: `Name HTTP server settings:
├── Listening address: :8000
└── Shutdown timeout: 1s`,
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
s := testCase.settings.String()
assert.Equal(t, testCase.s, s)
})
}
}