wip: 🔕 temporary commit

This commit is contained in:
Paramtamtam 2024-06-23 00:05:11 +04:00
parent b71475fcf7
commit 669aaf6a1e
No known key found for this signature in database
GPG Key ID: 366371698FAD0A2B
13 changed files with 515 additions and 33 deletions

View File

@ -7,10 +7,6 @@ FROM docker.io/library/golang:1.22-bookworm AS develop
ENV GOPATH="/var/tmp/go"
RUN set -x \
# renovate: source=github-releases name=abice/go-enum
&& ABICE_GOENUM_VERSION="0.6.0" \
&& GOBIN=/bin go install "github.com/abice/go-enum@v${ABICE_GOENUM_VERSION}" \
&& GOBIN=/bin go install golang.org/x/tools/cmd/goimports@latest \
&& GOBIN=/bin go install gotest.tools/gotestsum@latest \
&& go clean -cache -modcache \
# renovate: source=github-releases name=golangci/golangci-lint

2
go.mod
View File

@ -1,6 +1,6 @@
module gh.tarampamp.am/error-pages
go 1.21
go 1.22
require (
github.com/a8m/envsubst v1.4.2

View File

@ -2,15 +2,18 @@ package serve
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/urfave/cli/v3"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http"
)
type command struct {
@ -18,9 +21,9 @@ type command struct {
opt struct {
http struct { // our HTTP server
addr string
port uint16
readBufferSize uint
addr string
port uint16
// readBufferSize uint
}
}
}
@ -28,8 +31,9 @@ type command struct {
// NewCommand creates `serve` command.
func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
var (
cmd command
cfg = config.New()
cmd command
cfg = config.New()
env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
)
var (
@ -40,38 +44,38 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
jsonFormatFlag = cli.StringFlag{
Name: "json-format",
Usage: "override the default error page response in JSON format (Go templates are supported)",
Sources: cli.EnvVars("RESPONSE_JSON_FORMAT"),
Sources: env("RESPONSE_JSON_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Config: trim,
}
xmlFormatFlag = cli.StringFlag{
Name: "xml-format",
Usage: "override the default error page response in XML format (Go templates are supported)",
Sources: cli.EnvVars("RESPONSE_XML_FORMAT"),
Sources: env("RESPONSE_XML_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Config: trim,
}
templateNameFlag = cli.StringFlag{
Name: "template-name",
Aliases: []string{"t"},
Value: cfg.TemplateName,
Usage: "name of the template to use for rendering error pages",
Sources: cli.EnvVars("TEMPLATE_NAME"),
Sources: env("TEMPLATE_NAME"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Config: trim,
}
disableL10nFlag = cli.BoolFlag{
Name: "disable-l10n",
Usage: "disable localization of error pages (if the template supports localization)",
Value: cfg.L10n.Disable,
Sources: cli.EnvVars("DISABLE_L10N"),
Sources: env("DISABLE_L10N"),
OnlyOnce: true,
}
defaultCodeToRenderFlag = cli.UintFlag{
Name: "default-error-page",
Usage: "the code of the default (index page, when a code is not specified) error page to render",
Value: uint64(cfg.Default.CodeToRender),
Sources: cli.EnvVars("DEFAULT_ERROR_PAGE"),
Sources: env("DEFAULT_ERROR_PAGE"),
Validator: func(code uint64) error {
if code > 999 { //nolint:mnd
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
@ -85,7 +89,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
Name: "default-http-code",
Usage: "the default (index page, when a code is not specified) HTTP response code",
Value: uint64(cfg.Default.HttpCode),
Sources: cli.EnvVars("DEFAULT_HTTP_CODE"),
Sources: env("DEFAULT_HTTP_CODE"),
Validator: defaultCodeToRenderFlag.Validator,
OnlyOnce: true,
}
@ -93,7 +97,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
Name: "show-details",
Usage: "show request details in the error page response (if supported by the template)",
Value: cfg.ShowDetails,
Sources: cli.EnvVars("SHOW_DETAILS"),
Sources: env("SHOW_DETAILS"),
OnlyOnce: true,
}
proxyHeadersListFlag = cli.StringFlag{
@ -101,7 +105,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
Usage: "listed here HTTP headers will be proxied from the original request to the error page response " +
"(comma-separated list)",
Value: strings.Join(cfg.ProxyHeaders, ","),
Sources: cli.EnvVars("PROXY_HTTP_HEADERS"),
Sources: env("PROXY_HTTP_HEADERS"),
Validator: func(s string) error {
for _, raw := range strings.Split(s, ",") {
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
@ -112,17 +116,33 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
return nil
},
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Config: trim,
}
readBufferSizeFlag = cli.UintFlag{
Name: "read-buffer-size",
Usage: "customize the HTTP read buffer size (set per connection for reading requests, also limits the " +
"maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB " +
"headers, such as large cookies)",
DefaultText: "not set",
Sources: cli.EnvVars("READ_BUFFER_SIZE"),
OnlyOnce: true,
rotationModeFlag = cli.StringFlag{
Name: "rotation-mode",
Value: config.RotationModeDisabled.String(),
Usage: "templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
Sources: env("TEMPLATES_ROTATION_MODE"),
OnlyOnce: true,
Config: trim,
Validator: func(s string) error {
if _, err := config.ParseRotationMode(s); err != nil {
return err
}
return nil
},
}
// readBufferSizeFlag = cli.UintFlag{
// Name: "read-buffer-size",
// Usage: "customize the HTTP read buffer size (set per connection for reading requests, also limits the " +
// "maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB " +
// "headers, such as large cookies)",
// DefaultText: "not set",
// Sources: cli.EnvVars("READ_BUFFER_SIZE"),
// OnlyOnce: true,
// }
)
cmd.c = &cli.Command{
@ -133,12 +153,13 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
Action: func(ctx context.Context, c *cli.Command) error {
cmd.opt.http.addr = c.String(addrFlag.Name)
cmd.opt.http.port = uint16(c.Uint(portFlag.Name))
cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
// cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
cfg.TemplateName = c.String(templateNameFlag.Name)
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
cfg.Default.CodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name))
cfg.Default.HttpCode = uint16(c.Uint(defaultHTTPCodeFlag.Name))
cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
if c.IsSet(proxyHeadersListFlag.Name) {
@ -231,7 +252,8 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
&defaultHTTPCodeFlag,
&showDetailsFlag,
&proxyHeadersListFlag,
&readBufferSizeFlag,
&rotationModeFlag,
// &readBufferSizeFlag,
},
}
@ -240,5 +262,47 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
// Run current command.
func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config) error {
return nil // TODO: implement
var srv = appHttp.NewServer(ctx, log)
if err := srv.Register(cfg); err != nil {
return err
}
var startingErrCh = make(chan error, 1) // channel for server starting error
defer close(startingErrCh)
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
var now = time.Now()
defer func() {
log.Info("HTTP server stopped", zap.Duration("uptime", time.Since(now).Round(time.Millisecond)))
}()
log.Info("HTTP server starting",
zap.String("addr", cmd.opt.http.addr),
zap.Uint16("port", cmd.opt.http.port),
)
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}(startingErrCh)
// and wait for...
select {
case err := <-startingErrCh: // ..server starting error
return err
case <-ctx.Done(): // ..or context cancellation
const shutdownTimeout = 5 * time.Second
log.Info("HTTP server stopping", zap.Duration("with timeout", shutdownTimeout))
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
return err
}
}
return nil
}

View File

@ -47,6 +47,10 @@ type Config struct {
HttpCode uint16
}
// RotationMode allows to set the rotation mode for templates to switch between them automatically on startup,
// on each request, daily, hourly and so on.
RotationMode RotationMode
// ShowDetails determines whether to show additional details in the error response, extracted from the
// incoming request (if supported by the template).
ShowDetails bool

View File

@ -0,0 +1,87 @@
package config
import (
"fmt"
"strings"
)
// RotationMode represents the rotation mode for templates.
type RotationMode byte
const (
RotationModeDisabled RotationMode = iota // do not rotate templates, default
RotationModeRandomOnStartup // pick a random template on startup
RotationModeRandomOnEachRequest // pick a random template on each request
RotationModeRandomDaily // once a day switch to a random template
RotationModeRandomHourly // once an hour switch to a random template
)
// String returns a human-readable representation of the rotation mode.
func (rm RotationMode) String() string {
switch rm {
case RotationModeDisabled:
return "disabled"
case RotationModeRandomOnStartup:
return "random-on-startup"
case RotationModeRandomOnEachRequest:
return "random-on-each-request"
case RotationModeRandomDaily:
return "random-daily"
case RotationModeRandomHourly:
return "random-hourly"
}
return fmt.Sprintf("RotationMode(%d)", rm)
}
// RotationModes returns a slice of all rotation modes.
func RotationModes() []RotationMode {
return []RotationMode{
RotationModeDisabled,
RotationModeRandomOnStartup,
RotationModeRandomOnEachRequest,
RotationModeRandomDaily,
RotationModeRandomHourly,
}
}
// RotationModeStrings returns a slice of all rotation modes as strings.
func RotationModeStrings() []string {
var (
modes = RotationModes()
result = make([]string, len(modes))
)
for i := range modes {
result[i] = modes[i].String()
}
return result
}
// ParseRotationMode parses a rotation mode (case is ignored) based on the ASCII representation of the rotation mode.
// If the provided ASCII representation is invalid an error is returned.
func ParseRotationMode[T string | []byte](text T) (RotationMode, error) {
var mode string
if s, ok := any(text).(string); ok {
mode = s
} else {
mode = string(any(text).([]byte))
}
switch strings.ToLower(mode) {
case RotationModeDisabled.String(), "":
return RotationModeDisabled, nil // the empty string makes sense
case RotationModeRandomOnStartup.String():
return RotationModeRandomOnStartup, nil
case RotationModeRandomOnEachRequest.String():
return RotationModeRandomOnEachRequest, nil
case RotationModeRandomDaily.String():
return RotationModeRandomDaily, nil
case RotationModeRandomHourly.String():
return RotationModeRandomHourly, nil
}
return RotationMode(0), fmt.Errorf("unrecognized rotation mode: %q", mode)
}

View File

@ -0,0 +1,90 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/config"
)
func TestRotationMode_String(t *testing.T) {
t.Parallel()
assert.Equal(t, "disabled", config.RotationModeDisabled.String())
assert.Equal(t, "random-on-startup", config.RotationModeRandomOnStartup.String())
assert.Equal(t, "random-on-each-request", config.RotationModeRandomOnEachRequest.String())
assert.Equal(t, "random-daily", config.RotationModeRandomDaily.String())
assert.Equal(t, "random-hourly", config.RotationModeRandomHourly.String())
assert.Equal(t, "RotationMode(255)", config.RotationMode(255).String())
}
func TestRotationModes(t *testing.T) {
t.Parallel()
assert.Equal(t, []config.RotationMode{
config.RotationModeDisabled,
config.RotationModeRandomOnStartup,
config.RotationModeRandomOnEachRequest,
config.RotationModeRandomDaily,
config.RotationModeRandomHourly,
}, config.RotationModes())
}
func TestRotationModeStrings(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{
"disabled",
"random-on-startup",
"random-on-each-request",
"random-daily",
"random-hourly",
}, config.RotationModeStrings())
}
func TestParseRotationMode(t *testing.T) {
t.Parallel()
for name, _tt := range map[string]struct {
giveBytes []byte
giveString string
wantMode config.RotationMode
wantErrorMsg string
}{
"<empty string>": {giveString: "", wantMode: config.RotationModeDisabled},
"<empty bytes>": {giveBytes: []byte(""), wantMode: config.RotationModeDisabled},
"disabled": {giveString: "disabled", wantMode: config.RotationModeDisabled},
"disabled (bytes)": {giveBytes: []byte("disabled"), wantMode: config.RotationModeDisabled},
"random-on-startup": {giveString: "random-on-startup", wantMode: config.RotationModeRandomOnStartup},
"random-on-startup (bytes)": {giveBytes: []byte("random-on-startup"), wantMode: config.RotationModeRandomOnStartup},
"on-each-request": {giveString: "random-on-each-request", wantMode: config.RotationModeRandomOnEachRequest},
"daily": {giveString: "random-daily", wantMode: config.RotationModeRandomDaily},
"hourly": {giveString: "random-hourly", wantMode: config.RotationModeRandomHourly},
"foobar": {giveString: "foobar", wantErrorMsg: "unrecognized rotation mode: \"foobar\""},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
var (
mode config.RotationMode
err error
)
if tt.giveString != "" || tt.giveBytes == nil {
mode, err = config.ParseRotationMode(tt.giveString)
} else {
mode, err = config.ParseRotationMode(tt.giveBytes)
}
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
assert.Equal(t, tt.wantMode, mode)
} else {
assert.ErrorContains(t, err, tt.wantErrorMsg)
}
})
}
}

View File

@ -0,0 +1,12 @@
package error_page
import "net/http"
func New() http.Handler {
var body = []byte("error page")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(body)
})
}

View File

@ -0,0 +1,16 @@
package live
import (
"net/http"
)
// New creates a new handler that always returns "OK" with status code 200.
func New() http.Handler {
var body = []byte("OK")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
}

View File

@ -0,0 +1,26 @@
package live_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/live"
)
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
live.New().ServeHTTP(rr, req)
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
assert.Equal(t, rr.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), "OK")
}

View File

@ -0,0 +1,22 @@
package version
import (
"encoding/json"
"net/http"
"strings"
)
// New creates a handler that returns the version of the service in JSON format.
func New(ver string) http.Handler {
var body, _ = json.Marshal(struct { //nolint:errchkjson
Version string `json:"version"`
}{
Version: strings.TrimSpace(ver),
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
}

View File

@ -0,0 +1,25 @@
package version_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/version"
)
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
version.New("\t\n foo@bar ").ServeHTTP(rr, req)
assert.Equal(t, rr.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`)
}

View File

@ -0,0 +1,49 @@
package logreq
import (
"net/http"
"time"
"go.uber.org/zap"
)
// New creates a middleware for [http.ServeMux] that logs every incoming request.
//
// The skipper function should return true if the request should be skipped. It's ok to pass nil.
func New(log *zap.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if skipper != nil && skipper(r) {
next.ServeHTTP(w, r)
return
}
var now = time.Now()
defer func() {
var fields = []zap.Field{
zap.String("useragent", r.UserAgent()),
zap.String("method", r.Method),
zap.String("url", r.URL.String()),
zap.String("referer", r.Referer()),
zap.String("content type", w.Header().Get("Content-Type")),
zap.String("remote addr", r.RemoteAddr),
zap.String("method", r.Method),
zap.Duration("duration", time.Since(now).Round(time.Microsecond)),
}
if log.Level() <= zap.DebugLevel {
fields = append(fields,
zap.Any("request headers", r.Header.Clone()),
zap.Any("response headers", w.Header().Clone()),
)
}
log.Info("HTTP request processed", fields...)
}()
next.ServeHTTP(w, r)
})
}
}

91
internal/http/server.go Normal file
View File

@ -0,0 +1,91 @@
package http
import (
"context"
"net"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"gh.tarampamp.am/error-pages/internal/http/handlers/live"
"gh.tarampamp.am/error-pages/internal/http/handlers/version"
"gh.tarampamp.am/error-pages/internal/http/middleware/logreq"
)
// Server is an HTTP server for serving error pages.
type Server struct {
log *zap.Logger
server *http.Server
mux *http.ServeMux
}
// NewServer creates a new HTTP server.
func NewServer(baseCtx context.Context, log *zap.Logger) Server {
const (
readTimeout = 30 * time.Second
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
)
var (
mux = http.NewServeMux()
srv = &http.Server{
Handler: mux,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ReadHeaderTimeout: readTimeout,
MaxHeaderBytes: maxHeaderBytes,
ErrorLog: zap.NewStdLog(log),
BaseContext: func(net.Listener) context.Context { return baseCtx },
}
)
return Server{log: log, server: srv, mux: mux}
}
// Register server handlers, middlewares, etc.
func (s *Server) Register(cfg *config.Config) error {
// register middleware
s.server.Handler = logreq.New(s.log, func(r *http.Request) bool {
// skip logging healthcheck requests
return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck")
})(s.server.Handler)
{ // register handlers (https://go.dev/blog/routing-enhancements)
var errorPageHandler = error_page.New()
s.mux.Handle("/", errorPageHandler)
s.mux.Handle("/{any}", errorPageHandler)
var liveHandler = live.New()
s.mux.Handle("GET /health/live", liveHandler)
s.mux.Handle("GET /healthz", liveHandler)
s.mux.Handle("GET /live", liveHandler)
s.mux.Handle("GET /version", version.New(appmeta.Version()))
}
return nil
}
// Start server.
func (s *Server) Start(ip string, port uint16) error {
s.server.Addr = ip + ":" + strconv.Itoa(int(port))
return s.server.ListenAndServe()
}
// Stop server gracefully.
func (s *Server) Stop(timeout time.Duration) error {
var ctx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.server.Shutdown(ctx)
}