wip: 🔕 temporary commit

This commit is contained in:
Paramtamtam 2024-06-23 16:12:06 +04:00
parent 669aaf6a1e
commit 1b94bc367c
No known key found for this signature in database
GPG Key ID: 366371698FAD0A2B
10 changed files with 393 additions and 64 deletions

View File

@ -5,5 +5,4 @@
!/cmd
!/internal
!/templates
!/error-pages.yml
!/go.*

View File

@ -48,22 +48,19 @@ WORKDIR /tmp/rootfs
# prepare rootfs for runtime
RUN --mount=type=bind,source=.,target=/src set -x \
&& mkdir -p ./etc ./bin ./opt/html \
&& mkdir -p ./etc ./bin \
&& echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \
&& echo 'appuser:x:10001:' > ./etc/group \
&& cp -rv /src/templates ./opt/templates \
&& rm -v ./opt/templates/*.md \
&& cp -rv /src/error-pages.yml ./opt/error-pages.yml
&& echo 'appuser:x:10001:' > ./etc/group
# take the binary from the compile stage
COPY --from=compile /tmp/error-pages ./bin/error-pages
WORKDIR /tmp/rootfs/opt
# generate static error pages (for usage inside another docker images, for example)
RUN set -x \
&& ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \
&& ls -l ./html
## generate static error pages (for usage inside another docker images, for example)
#RUN set -x \
# && ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \
# && ls -l ./html
# -✂- and this is the final stage (an empty filesystem is used) -------------------------------------------------------
FROM scratch AS runtime
@ -98,9 +95,9 @@ WORKDIR /opt
# docs: https://docs.docker.com/reference/dockerfile/#healthcheck
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD [\
"/bin/error-pages", "--log-json", "healthcheck" \
"/bin/error-pages", "--log-format", "json", "healthcheck" \
]
ENTRYPOINT ["/bin/error-pages"]
CMD ["--log-json", "serve"]
CMD ["--log-format", "json", "serve"]

View File

@ -10,7 +10,7 @@ services:
web:
build: {target: runtime}
ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080
command: serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah --catch-all
command: --log-level debug serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah
develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/
watch: [{action: rebuild, path: .}]
security_opt: [no-new-privileges:true]

View File

@ -9,7 +9,6 @@ import (
_ "github.com/urfave/cli-docs/v3" // required for `go generate` to work
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal-old/env"
"gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/cli/serve"
@ -25,7 +24,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
Name: "log-level",
Value: logger.InfoLevel.String(),
Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Sources: cli.EnvVars(env.LogLevel.String()),
Sources: cli.EnvVars("LOG_LEVEL"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {
@ -41,7 +40,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
Name: "log-format",
Value: logger.ConsoleFormat.String(),
Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Sources: cli.EnvVars(env.LogFormat.String()),
Sources: cli.EnvVars("LOG_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {

View File

@ -4,13 +4,22 @@ import (
"net/http"
)
// New creates a new handler that always returns "OK" with status code 200.
// New creates a new handler that returns "OK" for GET and HEAD requests.
func New() http.Handler {
var body = []byte("OK")
var body = []byte("OK\n")
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)
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
case http.MethodHead:
w.WriteHeader(http.StatusOK)
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
})
}

View File

@ -13,14 +13,51 @@ import (
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
var handler = live.New()
live.New().ServeHTTP(rr, req)
t.Run("get", func(t *testing.T) {
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
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")
handler.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, "OK\n", rr.Body.String())
})
t.Run("head", func(t *testing.T) {
var (
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Code, http.StatusOK)
assert.Empty(t, rr.Header().Get("Content-Type"))
assert.Empty(t, rr.Body.Bytes())
})
t.Run("method not allowed", func(t *testing.T) {
for _, method := range []string{
http.MethodDelete,
http.MethodPatch,
http.MethodPost,
http.MethodPut,
} {
var (
req = httptest.NewRequest(method, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
}
})
}

View File

@ -15,8 +15,17 @@ func New(ver string) http.Handler {
})
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)
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
case http.MethodHead:
w.WriteHeader(http.StatusOK)
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
})
}

View File

@ -13,13 +13,51 @@ import (
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
var handler = version.New("\t\n foo@bar ")
version.New("\t\n foo@bar ").ServeHTTP(rr, req)
t.Run("get", func(t *testing.T) {
var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
assert.Equal(t, rr.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`)
handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8")
assert.Equal(t, rr.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`)
})
t.Run("head", func(t *testing.T) {
var (
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Code, http.StatusOK)
assert.Empty(t, rr.Header().Get("Content-Type"))
assert.Empty(t, rr.Body.Bytes())
})
t.Run("method not allowed", func(t *testing.T) {
for _, method := range []string{
http.MethodDelete,
http.MethodPatch,
http.MethodPost,
http.MethodPut,
} {
var (
req = httptest.NewRequest(method, "http://testing", http.NoBody)
rr = httptest.NewRecorder()
)
handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
}
})
}

View File

@ -4,6 +4,7 @@ import (
"context"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@ -22,7 +23,6 @@ import (
type Server struct {
log *zap.Logger
server *http.Server
mux *http.ServeMux
}
// NewServer creates a new HTTP server.
@ -33,45 +33,65 @@ func NewServer(baseCtx context.Context, log *zap.Logger) Server {
maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
)
var (
mux = http.NewServeMux()
srv = &http.Server{
Handler: mux,
return Server{
log: log,
server: &http.Server{
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
var (
liveHandler = live.New()
versionHandler = version.New(appmeta.Version())
errorPagesHandler = error_page.New()
errorPageRegex = regexp.MustCompile(`^/(\d{3})(?:\.html|\.htm)?$`) // TODO: rewrite to function
)
s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var url, method = r.URL.Path, r.Method
switch {
// live endpoints
case url == "/health/live" || url == "/health" || url == "/healthz" || url == "/live":
liveHandler.ServeHTTP(w, r)
// version endpoint
case url == "/version":
versionHandler.ServeHTTP(w, r)
// error pages endpoints:
// - /
// - /{code}.html
// - /{code}.htm
// - /{code}
case method == http.MethodGet && (url == "/" || errorPageRegex.MatchString(url)):
errorPagesHandler.ServeHTTP(w, r)
// wrong requests handling
default:
switch {
case method == http.MethodHead:
w.WriteHeader(http.StatusNotFound)
case method == http.MethodGet:
http.NotFound(w, r)
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
}
})
// apply 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
}

View File

@ -0,0 +1,221 @@
package http_test
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http"
)
func TestRouting(t *testing.T) {
var (
srv = appHttp.NewServer(context.Background(), zap.NewNop())
cfg = config.New()
)
require.NoError(t, srv.Register(&cfg))
var baseUrl, stopServer = startServer(t, &srv)
defer stopServer()
t.Run("health", func(t *testing.T) {
var routes = []string{"/health/live", "/health", "/healthz", "/live"}
t.Run("success (get)", func(t *testing.T) {
for _, route := range routes {
status, body, headers := sendRequest(t, http.MethodGet, baseUrl+route)
assert.Equal(t, http.StatusOK, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
})
t.Run("success (head)", func(t *testing.T) {
for _, route := range routes {
status, body, headers := sendRequest(t, http.MethodHead, baseUrl+route)
assert.Equal(t, http.StatusOK, status)
assert.Empty(t, body)
assert.Empty(t, headers.Get("Content-Type"))
}
})
t.Run("method not allowed", func(t *testing.T) {
for _, route := range routes {
var url = baseUrl + route
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
status, body, headers := sendRequest(t, method, url)
assert.Equal(t, http.StatusMethodNotAllowed, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
}
})
})
t.Run("version", func(t *testing.T) {
var url = baseUrl + "/version"
t.Run("success (get)", func(t *testing.T) {
status, body, headers := sendRequest(t, http.MethodGet, url)
assert.Equal(t, http.StatusOK, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "application/json")
})
t.Run("success (head)", func(t *testing.T) {
status, body, headers := sendRequest(t, http.MethodHead, url)
assert.Equal(t, http.StatusOK, status)
assert.Empty(t, body)
assert.Empty(t, headers.Get("Content-Type"))
})
t.Run("method not allowed", func(t *testing.T) {
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
status, body, headers := sendRequest(t, method, url)
assert.Equal(t, http.StatusMethodNotAllowed, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
})
})
t.Run("error page", func(t *testing.T) {
t.Skip("not implemented")
})
t.Run("errors handling", func(t *testing.T) {
var missingRoutes = []string{"/not-found", "/not-found/", "/not-found.html"}
t.Run("not found (get)", func(t *testing.T) {
for _, path := range missingRoutes {
status, body, headers := sendRequest(t, http.MethodGet, baseUrl+path)
assert.Equal(t, http.StatusNotFound, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
})
t.Run("not found (head)", func(t *testing.T) {
for _, path := range missingRoutes {
status, body, headers := sendRequest(t, http.MethodHead, baseUrl+path)
assert.Equal(t, http.StatusNotFound, status)
assert.Empty(t, body)
assert.Empty(t, headers.Get("Content-Type"))
}
})
t.Run("methods not allowed", func(t *testing.T) {
for _, path := range missingRoutes {
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
status, body, headers := sendRequest(t, method, baseUrl+path)
assert.Equal(t, http.StatusMethodNotAllowed, status)
assert.NotEmpty(t, body)
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
}
})
})
}
// sendRequest is a helper function to send an HTTP request and return its status code, body, and headers.
func sendRequest(t *testing.T, method, url string, headers ...map[string]string) (
status int,
body []byte,
_ http.Header,
) {
t.Helper()
req, reqErr := http.NewRequest(method, url, nil)
require.NoError(t, reqErr)
if len(headers) > 0 {
for key, value := range headers[0] {
req.Header.Add(key, value)
}
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, _ = io.ReadAll(resp.Body)
require.NoError(t, resp.Body.Close())
return resp.StatusCode, body, resp.Header
}
// startServer is a helper function to start an HTTP server and return its base URL and a stop function.
func startServer(t *testing.T, srv *appHttp.Server) (_ string, stop func()) {
t.Helper()
var (
port = getFreeTcpPort(t)
hostPort = fmt.Sprintf("%s:%d", "127.0.0.1", port)
)
go func() {
if err := srv.Start("127.0.0.1", port); err != nil && !errors.Is(err, http.ErrServerClosed) {
assert.NoError(t, err)
}
}()
// wait until the server starts
for {
if conn, err := net.DialTimeout("tcp", hostPort, time.Second); err == nil {
require.NoError(t, conn.Close())
break
}
<-time.After(5 * time.Millisecond)
}
return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(10*time.Millisecond)) }
}
// getFreeTcpPort is a helper function to get a free TCP port number.
func getFreeTcpPort(t *testing.T) uint16 {
t.Helper()
l, lErr := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, lErr)
port := l.Addr().(*net.TCPAddr).Port
require.NoError(t, l.Close())
// make sure port is closed
for {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
break
}
require.NoError(t, conn.Close())
<-time.After(5 * time.Millisecond)
}
return uint16(port)
}