Moving server files to ./server

This commit is contained in:
Corteza Monorepo Migrator
2022-11-14 09:26:39 +01:00
parent 204738f7c9
commit 683c7c56e2
5750 changed files with 0 additions and 26430 deletions
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"context"
"net/http"
)
// Debug context
type ctxKeyDebug struct{}
// Packs remote address to context
func DebugToContext(production bool) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), ctxKeyDebug{}, !production)))
})
}
}
// DebugFromContext returns remote IP address from context
func DebugFromContext(ctx context.Context) bool {
debug, ok := ctx.Value(ctxKeyDebug{}).(bool)
return ok && debug
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"context"
"net/http"
)
// Key to use when setting the request ID.
type ctxKeyRemoteAddr int
// RemoteAddrKey is the key that holds th unique request ID in a request context.
const remoteAddrKey ctxKeyRemoteAddr = 0
// Packs remote address to context
func RemoteAddrToContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), remoteAddrKey, req.RemoteAddr)))
})
}
// RemoteAddrFromContext returns remote IP address from context
func RemoteAddrFromContext(ctx context.Context) string {
v := ctx.Value(remoteAddrKey)
if str, ok := v.(string); ok {
return str
}
return ""
}
+170
View File
@@ -0,0 +1,170 @@
package api
// This code is modified version from
// https://github.com/titpetric/factory/tree/master/resputil
//
// Parts of the code are rewritten to allow greater flexibility
// but the general logic stays the same for now
import (
"encoding/json"
"fmt"
"github.com/cortezaproject/corteza-server/pkg/errors"
"net/http"
)
type (
successWrap struct {
Success struct {
Message string `json:"message"`
} `json:"success"`
}
CallFrame struct {
Function string `json:"function"`
File string `json:"file"`
Line int `json:"line"`
}
ErrorPayload struct {
Message string `json:"message"`
Context map[string]interface{} `json:"context,omitempty"`
Callstack []*CallFrame `json:"callstack,omitempty"`
}
)
// Success returns a structured success message for API responses
func Success(success ...string) *successWrap {
response := &successWrap{}
response.Success.Message = "OK"
if len(success) > 0 {
response.Success.Message = success[0]
}
return response
}
// OK returns the default Success message
func OK() *successWrap {
return Success()
}
// Writes response, according to type
//
// Primarily this function encodes given payload (directly or indirectly) as compact JSON
//
// In some specific scenarios, when:
// - debug mode is enabled,
// - no explicit accept header with /json mime-type is sent
// and,
//
// if payload is an error:
// it outputs formatted error with extended info
//
// if payload is non-error:
// it outputs formatted and indented JSON
//
func encode(w http.ResponseWriter, r *http.Request, payload interface{}) {
var (
err error
enc = json.NewEncoder(w)
)
switch c := payload.(type) {
case error:
err = c
case *successWrap:
// main key is "success"
w.Header().Add("Content-Type", "application/json")
if err = enc.Encode(c); err != nil {
err = fmt.Errorf("failed to encode response: %w", err)
}
default:
w.Header().Add("Content-Type", "application/json")
// main key is "response"
aux := struct {
Response interface{} `json:"response"`
}{c}
if err = enc.Encode(aux); err != nil {
err = fmt.Errorf("failed to encode response: %w", err)
}
}
if err != nil {
if err, is := err.(*errors.Error); is {
// trim out the base stack we don't care about...
_ = err.Apply(errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"))
}
errors.ServeHTTP(w, r, err, !DebugFromContext(r.Context()))
return
}
}
// Send handles first non-nil (and non-empty) payload and encodes it or it's results (when fn)
//
// See encode() for details
func Send(w http.ResponseWriter, r *http.Request, rr ...interface{}) {
for _, rsp := range rr {
switch c := rsp.(type) {
case nil:
// this will match a nil error
continue
case *successWrap:
encode(w, r, c)
case func(http.ResponseWriter, *http.Request):
c(w, r)
case func() ([]byte, error):
result, err := c()
Send(w, r, err, result)
case func() (interface{}, error):
result, err := c()
Send(w, r, err, result)
case func() error:
err := c()
if err == nil {
continue
}
encode(w, r, err)
case error:
encode(w, r, c)
case []byte:
if len(c) == 0 {
continue
}
if _, err := w.Write(c); err != nil {
panic(err)
}
return
case string:
if c == "" {
continue
}
encode(w, r, c)
case bool:
if !c {
continue
}
encode(w, r, c)
default:
encode(w, r, c)
}
// Exit on the first output...
return
}
encode(w, r, false)
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"io/ioutil"
"net/http/httptest"
)
func TestTests(t *testing.T) {
testResponse := func(output interface{}) string {
w := httptest.NewRecorder()
r := &http.Request{Header: http.Header{}}
r.Header.Add("accept", "application/json")
Send(w, r, output)
body, _ := ioutil.ReadAll(w.Result().Body)
return string(body)
}
var cc = []struct {
name string
inp interface{}
out string
}{
{"nil", nil, `{"response":false}`},
{"bool true", true, `{"response":true}`},
{"bool false", false, `{"response":false}`},
{"string empty", "", `{"response":false}`},
{"string", "string", `{"response":"string"}`},
{"int zero", 0, `{"response":0}`},
{"int non-zero", 1337, `{"response":1337}`},
{"int sub-zero", -1, `{"response":-1}`},
{"error nil", func() error { return nil }, `{"response":false}`},
{"error", func() error { return fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
{"value + error", func() (interface{}, error) { return "string response", fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
{"empty value + error", func() (interface{}, error) { return "", fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
{"value + empty error", func() (interface{}, error) { return "string response", nil }, `{"response":"string response"}`},
{"success default", Success(), `{"success":{"message":"OK"}}`},
{"ok", OK(), `{"success":{"message":"OK"}}`},
{"success custom", Success("string"), `{"success":{"message":"string"}}`},
{"error stdlib", fmt.Errorf("string"), `{"error":{"message":"string"}}`},
{"error stdlib nil", func() interface{} { return func() error { return nil }() }(), `{"response":false}`},
{"func json nil", func() ([]byte, error) { return json.Marshal(nil) }, `null`},
{"func json false", func() ([]byte, error) { return json.Marshal(false) }, `false`},
{"func json 0", func() ([]byte, error) { return json.Marshal(0) }, `0`},
{"func json empty string", func() ([]byte, error) { return json.Marshal("") }, `""`},
{"func writer/req", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("foo")) }, `foo`},
{"custom struct", struct {
Name string `json:"name"`
}{"Corteza"}, `{"response":{"name":"Corteza"}}`},
}
for _, c := range cc {
t.Run(c.name, func(t *testing.T) {
got := strings.TrimSpace(testResponse(c.inp))
if got != strings.TrimSpace(c.out) {
t.Errorf("got %#v, expected %#v", got, c.out)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"net/http"
"github.com/go-chi/cors"
)
// Sets up default CORS rules to use as a middleware
func handleCORS(next http.Handler) http.Handler {
return cors.New(cors.Options{
AllowedOrigins: []string{
"http://*",
"https://*",
},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{
"Accept",
"Authorization",
"Content-Type",
"X-CSRF-ID",
},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}).Handler(next)
}
+50
View File
@@ -0,0 +1,50 @@
package server
import (
"fmt"
"net/http"
"github.com/cortezaproject/corteza-server/pkg/corredor"
"github.com/cortezaproject/corteza-server/pkg/eventbus"
"github.com/davecgh/go-spew/spew"
"github.com/go-chi/chi/v5"
)
func debugRoutes(r chi.Routes) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var printRoutes func(chi.Routes, string)
printRoutes = func(r chi.Routes, pfix string) {
routes := r.Routes()
for _, route := range routes {
if route.SubRoutes != nil && len(route.SubRoutes.Routes()) > 0 {
printRoutes(route.SubRoutes, pfix+route.Pattern[:len(route.Pattern)-2])
} else {
if route.Handlers["*"] != nil {
fmt.Fprintf(w, "%-8s %-80s\n", "*", pfix+route.Pattern)
continue
}
for method := range route.Handlers {
fmt.Fprintf(w, "%-8s %-80s\n", method, pfix+route.Pattern)
}
}
}
}
printRoutes(r, "")
}
}
func debugEventbus() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
spew.Fdump(w, eventbus.Service().Debug())
}
}
func debugCorredor() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
spew.Fdump(w, corredor.Service().Debug())
}
}
+53
View File
@@ -0,0 +1,53 @@
package server
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.uber.org/atomic"
)
type (
// demux (demultiplexer) routes request to one of the underlying routers
// according to current state
demux struct {
state *atomic.Uint32
routers map[uint32]chi.Router
}
)
var _ http.Handler = &demux{}
func Demux(state uint32, r chi.Router) *demux {
return &demux{
state: atomic.NewUint32(state),
routers: map[uint32]chi.Router{state: r},
}
}
func (d *demux) State(s uint32) {
d.state.Store(s)
}
func (d *demux) Router(s uint32, r chi.Router) {
d.routers[s] = r
}
func (d *demux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer panicRecovery(r.Context(), w)
var (
state = d.state.Load()
router, exists = d.routers[state]
)
if !exists {
_, _ = fmt.Fprintf(w, "unconfigured request demultiplexor state (%d)", state)
w.WriteHeader(http.StatusInternalServerError)
return
}
router.ServeHTTP(w, r)
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"bytes"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"github.com/cortezaproject/corteza-server/assets"
"github.com/cortezaproject/corteza-server/pkg/api"
"github.com/cortezaproject/corteza-server/pkg/auth"
"github.com/cortezaproject/corteza-server/pkg/errors"
"github.com/cortezaproject/corteza-server/pkg/healthcheck"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/cortezaproject/corteza-server/pkg/version"
"github.com/cortezaproject/corteza-server/webconsole"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
)
// routes used when server is in waiting mode
func waitingRoutes(log *zap.Logger, httpOpt options.HttpServerOpt) (r chi.Router) {
r = chi.NewRouter()
r.Use(handleCORS)
mountServiceHandlers(r, log, httpOpt, waiting)
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
// For non GET requests, return 503 (service unavailable)
errors.ServeHTTPWithCode(w, r,
http.StatusServiceUnavailable,
fmt.Errorf("corteza server initializing"),
true,
)
})
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Refresh the page in 15 seconds
w.Header().Set("Refresh", "15; url=/")
_, _ = fmt.Fprint(w, "Corteza server initializing\n\n")
if httpOpt.EnableHealthcheckRoute {
healthcheck.Defaults().Run(r.Context()).WriteTo(w)
}
})
return
}
// routes used when server in shutdown mode
func shutdownRoutes() (r chi.Router) {
r = chi.NewRouter()
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
// For non GET requests, return 503 (service unavailable)
errors.ServeHTTPWithCode(w, r,
http.StatusServiceUnavailable,
fmt.Errorf("corteza server shutting down"),
true,
)
})
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Refresh the page in 15 seconds
w.Header().Set("Refresh", "15; url=/")
_, _ = fmt.Fprint(w, "corteza server shutting down")
})
return
}
// routes used when in active mode
func activeRoutes(log *zap.Logger, mountable []func(r chi.Router), opts *options.Options) (r chi.Router) {
r = chi.NewRouter()
r.Use(handleCORS)
httpOpt := opts.HTTPServer
authOpt := opts.Auth
envOpt := opts.Environment
r.Route("/"+strings.TrimPrefix(httpOpt.BaseUrl, "/"), func(r chi.Router) {
// Reports error to Sentry if enabled
if httpOpt.EnablePanicReporting {
r.Use(sentryMiddleware())
}
if httpOpt.EnableMetrics {
// Metrics tracking middleware
r.Use(metricsMiddleware(httpOpt.MetricsServiceLabel))
}
// Handle panic (sets 500 server error headers)
//r.Use(handlePanic)
// Base middleware, CORS, RealIP, RequestID, context-logger
r.Use(BaseMiddleware(envOpt.IsProduction(), log)...)
// Logging request if enabled
if httpOpt.LogRequest {
r.Use(LogRequest)
}
// Logging response if enabled
if httpOpt.LogResponse {
r.Use(LogResponse)
}
// Verifies JWT in headers, cookies, ...
r.Use(auth.HttpTokenVerifier)
for _, mount := range mountable {
mount(r)
}
if httpOpt.EnableMetrics {
metricsMount(r, httpOpt.MetricsUsername, httpOpt.MetricsPassword)
}
})
if httpOpt.BaseUrl != "/" {
r.Handle("/", http.RedirectHandler(httpOpt.BaseUrl, http.StatusTemporaryRedirect))
}
mountServiceHandlers(r, log, httpOpt, active)
r.HandleFunc(httpOpt.ApiBaseUrl, handleStaticPages(log, httpOpt, authOpt, "api-landing.html"))
r.HandleFunc(httpOpt.ApiBaseUrl+"/", handleStaticPages(log, httpOpt, authOpt, "api-landing.html"))
r.NotFound(handleStaticPages(log, httpOpt, authOpt, "api-404.html"))
return
}
func mountServiceHandlers(r chi.Router, log *zap.Logger, opt options.HttpServerOpt, state uint32) {
if opt.WebConsoleEnabled {
path := "/console"
log.Info("web console enabled (HTTP_SERVER_WEB_CONSOLE_ENABLED=true): " + path)
r.Route(path, func(r chi.Router) {
if len(opt.WebConsolePassword) > 0 {
credentials := map[string]string{
opt.WebConsoleUsername: opt.WebConsolePassword,
}
r.Use(middleware.BasicAuth("web-console", credentials))
} else {
// warn only in waiting state to avoid repeated log messages
if state == waiting {
// warn the user regardless of what environment Corteza is running in.
log.Warn("SECURITY RISK: web console is enabled and unprotected, set " +
"HTTP_SERVER_WEB_CONSOLE_USERNAME, HTTP_SERVER_WEB_CONSOLE_PASSWORD " +
"if not running in development environment!")
}
}
webconsole.Mount(r)
mountDebugLogViewer(r, log)
// redirect from /console to /console/ui/
r.Mount("/", http.RedirectHandler(path+"/ui", http.StatusTemporaryRedirect))
})
}
if opt.EnableDebugRoute {
mountDebugHandler(r, log)
}
if opt.EnableVersionRoute {
mountVersionHandler(r, log, opt.BaseUrl)
}
if opt.EnableHealthcheckRoute {
mountHealthCheckHandler(r, log, opt.BaseUrl)
}
}
// @todo move all these routes under /console and
// output JSON instead of plain raw text
func mountDebugHandler(r chi.Router, log *zap.Logger) {
log.Debug("route debugger enabled: /__routes")
r.Get("/__routes", debugRoutes(r))
log.Debug("profiler enabled: /debug/pprof")
r.Mount("/debug", middleware.Profiler())
log.Debug("eventbus handlers debug enabled: /__eventbus")
r.Get("/__eventbus", debugEventbus())
log.Debug("corredor service debug enabled: /__corredor")
r.Get("/__corredor", debugCorredor())
}
func mountVersionHandler(r chi.Router, log *zap.Logger, basePath string) {
var (
dPath = "/version"
sPath = path.Join(basePath, dPath)
handler = func(w http.ResponseWriter, r *http.Request) {
api.Send(w, r, struct {
BuildTime string `json:"buildTime"`
Version string `json:"version"`
}{version.BuildTime, version.Version})
}
)
r.Get(dPath, handler)
log.Debug("version route enabled: " + dPath)
if dPath != sPath {
r.Get(sPath, handler)
log.Debug("version route enabled: " + sPath)
}
}
func mountHealthCheckHandler(r chi.Router, log *zap.Logger, basePath string) {
// default & sub path for health-check endpoint
var (
dPath = "/healthcheck"
sPath = path.Join(basePath, dPath)
)
r.Get(dPath, healthcheck.HttpHandler())
log.Debug("health check route enabled: " + sPath)
if dPath != sPath {
r.Get(sPath, healthcheck.HttpHandler())
log.Debug("health check route enabled: " + sPath)
}
}
func mountDebugLogViewer(r chi.Router, log *zap.Logger) {
var (
path = "/server-log-feed"
)
r.Get(path+".json", func(w http.ResponseWriter, r *http.Request) {
var (
after int = 0
limit int = 100
err error
q = r.URL.Query()
)
if aux := q.Get("after"); len(aux) > 0 {
after, err = strconv.Atoi(aux)
if err != nil {
errors.ProperlyServeHTTP(w, r, errors.InvalidData("invalid value format for after: %v", err), false)
return
}
}
if aux := q.Get("limit"); len(aux) > 0 {
limit, err = strconv.Atoi(aux)
if err != nil {
errors.ProperlyServeHTTP(w, r, errors.InvalidData("invalid value format for limit: %v", err), false)
return
}
}
_, _ = logger.WriteLogBuffer(w, after, limit)
})
}
func handleStaticPages(log *zap.Logger, hOpt options.HttpServerOpt, aOpt options.AuthOpt, file string) http.HandlerFunc {
// "good-enough" for now, plan to move to templates when
// merging with auth
const linkTpl = `<a class="btn btn-light font-weight-bold text-dark m-2" href="%s">%s</a>`
var (
links = make([]string, 0)
buf []byte
placeholder = []byte("<!-- links -->")
)
links = append(links, fmt.Sprintf(linkTpl, aOpt.BaseURL, "Login"))
if hOpt.ApiEnabled {
links = append(links, fmt.Sprintf(linkTpl, "https://docs.cortezaproject.org/", "Documentation"))
}
if hOpt.WebConsoleEnabled {
links = append(links, fmt.Sprintf(linkTpl, "/console", "Console"))
}
page, err := assets.Files(log, hOpt.AssetsPath).Open(file)
if err != nil {
log.Warn("could not open static page", zap.String("file", file), zap.Error(err))
}
buf, err = io.ReadAll(page)
if err != nil {
log.Warn("could not prepare static page", zap.String("file", file), zap.Error(err))
}
buf = bytes.ReplaceAll(buf, placeholder, []byte(strings.Join(links, "")))
return func(w http.ResponseWriter, r *http.Request) {
if page == nil {
// fallback to default 404 handler
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
w.Write(buf)
}
}
+76
View File
@@ -0,0 +1,76 @@
package server
import (
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
"net/http"
"strings"
"time"
)
// contextLogger middleware binds logger to request's context.
//
// This allows us to use logger from context (with requestID)
// inside our (generated) handlers and controllers
func contextLogger(log *zap.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
var requestID = middleware.GetReqID(req.Context())
w.Header().Add("X-Request-Id", requestID)
req = req.WithContext(logger.ContextWithValue(
req.Context(),
log.With(zap.String("requestID", requestID)).Named("rest"),
))
next.ServeHTTP(w, req)
})
}
}
// LogRequest middleware logs request details
//
// It uses logger from context, see contextLogger()
func LogRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
var remote = req.RemoteAddr
if l := strings.LastIndex(remote, ":"); l > -1 {
remote = remote[:l]
}
logger.ContextValue(req.Context()).Info(
"HTTP request "+req.Method+" "+req.URL.Path,
zap.String("method", req.Method),
zap.String("path", req.URL.Path),
zap.Int64("size", req.ContentLength),
zap.String("remote", remote),
)
next.ServeHTTP(w, req)
})
}
// LogResponse middleware logs response details
//
// It uses logger from context, see contextLogger()
func LogResponse(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
wrapped := middleware.NewWrapResponseWriter(w, req.ProtoMajor)
t := time.Now()
defer func() {
logger.ContextValue(req.Context()).Info(
"HTTP response "+req.Method+" "+req.URL.Path,
zap.String("method", req.Method),
zap.String("path", req.URL.Path),
zap.Int("status", wrapped.Status()),
zap.Int("size", wrapped.BytesWritten()),
zap.Float64("duration", time.Since(t).Seconds()),
)
}()
next.ServeHTTP(wrapped, req)
})
}
+24
View File
@@ -0,0 +1,24 @@
package server
import (
"github.com/99designs/basicauth-go"
"net/http"
"github.com/766b/chi-prometheus"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// MetricsMiddleware is the request logger that provides metrics to prometheus
func metricsMiddleware(name string) func(http.Handler) http.Handler {
return chiprometheus.NewMiddleware(name)
}
func metricsMount(r chi.Router, username, password string) {
r.Route("/metrics", func(r chi.Router) {
r.Use(basicauth.New("Metrics", map[string][]string{
username: {password},
}))
r.Handle("/", promhttp.Handler())
})
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"runtime/debug"
"github.com/cortezaproject/corteza-server/pkg/api"
"github.com/cortezaproject/corteza-server/pkg/locale"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
)
func BaseMiddleware(isProduction bool, log *zap.Logger) []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
handleCORS,
locale.DetectLanguage(locale.Global()),
middleware.RealIP,
api.RemoteAddrToContext,
middleware.RequestID,
api.DebugToContext(isProduction),
contextLogger(log),
}
}
func sentryMiddleware() func(http.Handler) http.Handler {
return sentryhttp.New(sentryhttp.Options{
Repanic: true,
}).Handle
}
func panicRecovery(ctx context.Context, w http.ResponseWriter) {
if err := recover(); err != nil {
if _, has := os.LookupEnv("LOG_DEBUG"); has {
println("================================================================================")
fmt.Printf("%v\n", err)
println("--------------------------------------------------------------------------------")
debug.PrintStack()
println("================================================================================")
} else {
log := logger.ContextValue(ctx)
if err, ok := err.(error); ok {
log = log.With(zap.Error(err))
} else {
log = log.With(zap.Any("recover-value", err))
}
log.Debug("crashed on http request", zap.ByteString("stack", debug.Stack()))
}
w.WriteHeader(500)
if _, has := os.LookupEnv("DEBUG_DUMP_STACK_IN_RESPONSE"); has {
// Provide nice call stack on endpoint when
// we crash
_, _ = w.Write(debug.Stack())
}
return
}
}
+125
View File
@@ -0,0 +1,125 @@
package server
import (
"context"
"net"
"net/http"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type (
server struct {
log *zap.Logger
opts *options.Options
endpoints []func(r chi.Router)
// last error
err error
demux *demux
}
)
const (
waiting uint32 = iota
active
shutdown
)
// New initializes new HTTP server with special powers
// Server is started as early as possible and with a special request handler
// that demultiplexes request to one of the configured routers according to the server state.
//
// Waiting state
// This is initial state with the ofllowing route handlers:
// - /version
// - /healthcheck
func New(log *zap.Logger, opts *options.Options) *server {
s := &server{
endpoints: make([]func(r chi.Router), 0),
log: log.Named("http"),
opts: opts,
}
s.demux = Demux(waiting, waitingRoutes(s.log.Named("waiting"), s.opts.HTTPServer))
s.demux.Router(shutdown, shutdownRoutes())
return s
}
func (s *server) LastError() error {
return s.err
}
func Test(o *options.Options) error {
listener, err := net.Listen("tcp", o.HTTPServer.Addr)
if err != nil {
return err
}
if err = listener.Close(); err != nil {
return err
}
return nil
}
// Activate reconfigures server to use active routes
func (s *server) Activate(mm ...func(chi.Router)) {
s.demux.Router(active, activeRoutes(s.log, mm, s.opts))
s.log.Debug("entering active state")
s.demux.State(active)
}
// Shutdown reconfigures server to use shutdown routes
func (s *server) Shutdown() {
s.log.Debug("entering shutdown state")
s.demux.State(shutdown)
}
func (s server) Serve(ctx context.Context) {
var (
listener net.Listener
)
s.log.Info(
"starting HTTP server",
zap.String("path-prefix", s.opts.HTTPServer.BaseUrl),
zap.String("address", s.opts.HTTPServer.Addr),
)
listener, s.err = net.Listen("tcp", s.opts.HTTPServer.Addr)
if s.err != nil {
s.log.Error("cannot start server", zap.Error(s.err))
return
}
go func() {
srv := http.Server{
Handler: s.demux,
// use root context as server's base context and as a basis for
// context for all requests
// this enables us to send cancellation down to every request
BaseContext: func(listener net.Listener) context.Context { return ctx },
}
s.err = srv.Serve(listener)
}()
<-ctx.Done()
if s.err == nil {
s.err = ctx.Err()
if s.err == context.Canceled {
s.err = nil
}
}
s.log.Info("HTTP server stopped", zap.Error(s.err))
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"context"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"go.uber.org/zap"
)
// WaitFor sets up a simple status page, delays execution and probes services
func (s server) WaitFor(ctx context.Context) {
var (
opt = s.opts.WaitFor
services = opt.GetServices()
)
if len(services) == 0 && opt.Delay == 0 {
// Nothing to do here..
return
}
var (
log = s.log.Named("wait-for")
depChan = make(chan struct{})
wg sync.WaitGroup
serviceAddr string
serviceURL *url.URL
err error
)
// Setup a simple HTTP server that will inform the impatient users
listener, err := net.Listen("tcp", s.opts.HTTPServer.Addr)
if err != nil {
s.log.Error("cannot start server", zap.Error(err))
os.Exit(1)
}
defer listener.Close()
go func() {
router := chi.NewRouter()
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPreconditionFailed)
w.Write([]byte("waiting for services..."))
})
_ = http.Serve(listener, router)
}()
if opt.Delay > 0 {
s.log.Info("delaying", zap.Duration("delay", opt.Delay))
// First delay execution
select {
case <-ctx.Done():
log.Debug("canceled")
return
case <-time.After(opt.Delay):
// all good...
}
}
if len(services) == 0 {
return
}
log.Info("waiting for services", zap.Strings("services", services))
// Probe services
wg.Add(len(services))
go func() {
for _, service := range services {
slog := log.With(zap.String("service", service))
go func(ctx context.Context, service string) {
defer wg.Done()
if serviceAddr, serviceURL, err = s.resolveService(service); err != nil {
log.Error("could not resolve service", zap.Error(err))
}
for {
ctx, cancelFn := context.WithTimeout(ctx, opt.ServicesProbeTimeout)
defer cancelFn()
if serviceURL == nil {
if err = s.probeService(ctx, serviceAddr); err != nil {
slog.Warn("service probe failed", zap.Error(err))
time.Sleep(opt.ServicesProbeInterval)
continue
}
} else {
if err = s.probeServiceURL(ctx, serviceURL); err != nil {
slog.Warn("service URL probe failed", zap.Error(err))
time.Sleep(opt.ServicesProbeInterval)
continue
}
}
slog.Debug("service ready")
return
}
}(ctx, service)
}
wg.Wait()
close(depChan)
}()
select {
case <-ctx.Done():
log.Debug("canceled")
return
case <-depChan: // services are ready
log.Debug("all services ready")
return
case <-time.After(opt.ServicesTimeout):
log.Debug("services not ready")
os.Exit(1)
}
}
func (s server) resolveService(service string) (addr string, u *url.URL, err error) {
addr = service
if strings.Contains(addr, "://") {
// Is service an URL?
u, err = url.Parse(addr)
if err != nil {
return
}
addr = u.Host
if u.Port() == "" {
if u.Scheme == "https" {
addr += ":443"
}
}
}
// Default to port 80
if !strings.Contains(addr, ":") {
addr += ":80"
}
return
}
func (s server) probeService(ctx context.Context, addr string) (err error) {
if err != nil {
return err
}
dialer := net.Dialer{}
_, err = dialer.DialContext(ctx, "tcp", addr)
return
}
func (s server) probeServiceURL(ctx context.Context, u *url.URL) error {
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return errors.Wrap(err, "failed to assemble service request")
}
rsp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return errors.Wrap(err, "service URL request failed")
}
defer rsp.Body.Close()
if rsp.StatusCode == http.StatusOK {
return nil
}
return errors.Errorf("service responded with unexpected status '%s'", rsp.Status)
}