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 !/cmd
!/internal !/internal
!/templates !/templates
!/error-pages.yml
!/go.* !/go.*

View File

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

View File

@ -10,7 +10,7 @@ services:
web: web:
build: {target: runtime} build: {target: runtime}
ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080 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/ develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/
watch: [{action: rebuild, path: .}] watch: [{action: rebuild, path: .}]
security_opt: [no-new-privileges:true] 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-docs/v3" // required for `go generate` to work
"github.com/urfave/cli/v3" "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/appmeta"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck" "gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/cli/serve" "gh.tarampamp.am/error-pages/internal/cli/serve"
@ -25,7 +24,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
Name: "log-level", Name: "log-level",
Value: logger.InfoLevel.String(), Value: logger.InfoLevel.String(),
Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")", Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Sources: cli.EnvVars(env.LogLevel.String()), Sources: cli.EnvVars("LOG_LEVEL"),
OnlyOnce: true, OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true}, Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error { Validator: func(s string) error {
@ -41,7 +40,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
Name: "log-format", Name: "log-format",
Value: logger.ConsoleFormat.String(), Value: logger.ConsoleFormat.String(),
Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")", Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Sources: cli.EnvVars(env.LogFormat.String()), Sources: cli.EnvVars("LOG_FORMAT"),
OnlyOnce: true, OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true}, Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error { Validator: func(s string) error {

View File

@ -4,13 +4,22 @@ import (
"net/http" "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 { func New() http.Handler {
var body = []byte("OK") var body = []byte("OK\n")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(body) _, _ = 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) { func TestServeHTTP(t *testing.T) {
t.Parallel() t.Parallel()
var handler = live.New()
t.Run("get", func(t *testing.T) {
var ( var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
) )
live.New().ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
assert.Equal(t, rr.Code, http.StatusOK) assert.Equal(t, rr.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), "OK") 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(body) _, _ = 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) { func TestServeHTTP(t *testing.T) {
t.Parallel() t.Parallel()
var handler = version.New("\t\n foo@bar ")
t.Run("get", func(t *testing.T) {
var ( var (
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
) )
version.New("\t\n foo@bar ").ServeHTTP(rr, req) 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.Code, http.StatusOK)
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`) 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" "context"
"net" "net"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -22,7 +23,6 @@ import (
type Server struct { type Server struct {
log *zap.Logger log *zap.Logger
server *http.Server server *http.Server
mux *http.ServeMux
} }
// NewServer creates a new HTTP server. // 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 maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
) )
var ( return Server{
mux = http.NewServeMux() log: log,
srv = &http.Server{ server: &http.Server{
Handler: mux,
ReadTimeout: readTimeout, ReadTimeout: readTimeout,
WriteTimeout: writeTimeout, WriteTimeout: writeTimeout,
ReadHeaderTimeout: readTimeout, ReadHeaderTimeout: readTimeout,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
ErrorLog: zap.NewStdLog(log), ErrorLog: zap.NewStdLog(log),
BaseContext: func(net.Listener) context.Context { return baseCtx }, BaseContext: func(net.Listener) context.Context { return baseCtx },
},
} }
)
return Server{log: log, server: srv, mux: mux}
} }
// Register server handlers, middlewares, etc. // Register server handlers, middlewares, etc.
func (s *Server) Register(cfg *config.Config) error { 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 { s.server.Handler = logreq.New(s.log, func(r *http.Request) bool {
// skip logging healthcheck requests // skip logging healthcheck requests
return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck") return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck")
})(s.server.Handler) })(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 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)
}