diff --git a/pkg/api/http.go b/pkg/api/http.go new file mode 100644 index 0000000..50c2140 --- /dev/null +++ b/pkg/api/http.go @@ -0,0 +1,94 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/stefanprodan/k8s-podinfo/pkg/version" + "go.uber.org/zap" +) + +func versionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("X-API-Version", version.VERSION) + r.Header.Set("X-API-Revision", version.REVISION) + + next.ServeHTTP(w, r) + }) +} + +func copyTracingHeaders(from *http.Request, to *http.Request) { + headers := []string{ + "x-request-id", + "x-b3-traceid", + "x-b3-spanid", + "x-b3-parentspanid", + "x-b3-sampled", + "x-b3-flags", + "x-ot-span-context", + } + + for i := range headers { + headerValue := from.Header.Get(headers[i]) + if len(headerValue) > 0 { + to.Header.Set(headers[i], headerValue) + } + } +} + +func (s *Server) JSONResponse(w http.ResponseWriter, r *http.Request, result interface{}) { + body, err := json.Marshal(result) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + s.logger.Error("JSON marshal failed", zap.Error(err)) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + w.Write(prettyJSON(body)) +} + +func (s *Server) JSONResponseCode(w http.ResponseWriter, r *http.Request, result interface{}, responseCode int) { + body, err := json.Marshal(result) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + s.logger.Error("JSON marshal failed", zap.Error(err)) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(responseCode) + w.Write(prettyJSON(body)) +} + +func (s *Server) ErrorResponse(w http.ResponseWriter, r *http.Request, error string, code int) { + data := struct { + Code int `json:"code"` + Message string `json:"message"` + }{ + Code: code, + Message: error, + } + + body, err := json.Marshal(data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + s.logger.Error("JSON marshal failed", zap.Error(err)) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + w.Write(prettyJSON(body)) +} + +func prettyJSON(b []byte) []byte { + var out bytes.Buffer + json.Indent(&out, b, "", " ") + return out.Bytes() +} diff --git a/pkg/server/instrument.go b/pkg/api/metrics.go similarity index 91% rename from pkg/server/instrument.go rename to pkg/api/metrics.go index 12af390..6c8bac4 100644 --- a/pkg/server/instrument.go +++ b/pkg/api/metrics.go @@ -1,4 +1,4 @@ -package server +package api import ( "bufio" @@ -13,12 +13,12 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -type Instrument struct { +type PrometheusMiddleware struct { Histogram *prometheus.HistogramVec Counter *prometheus.CounterVec } -func NewInstrument() *Instrument { +func NewPrometheusMiddleware() *PrometheusMiddleware { // used for monitoring and alerting (RED method) histogram := prometheus.NewHistogramVec(prometheus.HistogramOpts{ Subsystem: "http", @@ -39,13 +39,13 @@ func NewInstrument() *Instrument { prometheus.MustRegister(histogram) prometheus.MustRegister(counter) - return &Instrument{ + return &PrometheusMiddleware{ Histogram: histogram, Counter: counter, } } -func (i Instrument) Wrap(next http.Handler) http.Handler { +func (i *PrometheusMiddleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { begin := time.Now() interceptor := &interceptor{ResponseWriter: w, statusCode: http.StatusOK} diff --git a/pkg/api/mock.go b/pkg/api/mock.go new file mode 100644 index 0000000..5676f37 --- /dev/null +++ b/pkg/api/mock.go @@ -0,0 +1,32 @@ +package api + +import ( + "time" + + "go.uber.org/zap" + "github.com/gorilla/mux" +) + +func NewMockServer() *Server { + config := &Config{ + Port: "9898", + HttpServerShutdownTimeout: 5 * time.Second, + HttpServerTimeout: 30 * time.Second, + BackendURL: "", + ConfigPath: "/config", + DataPath: "/data", + HttpClientTimeout: 30 * time.Second, + UIColor: "blue", + UIPath: ".ui", + UIMessage: "Greetings", + Hostname: "localhost", + } + + logger, _ := zap.NewDevelopment() + + return &Server{ + router: mux.NewRouter(), + logger: logger, + config: config, + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go new file mode 100644 index 0000000..d35b5de --- /dev/null +++ b/pkg/api/server.go @@ -0,0 +1,168 @@ +package api + +import ( + "context" + "fmt" + "net/http" + _ "net/http/pprof" + "strings" + "sync/atomic" + "time" + + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/stefanprodan/k8s-podinfo/pkg/fscache" + "go.uber.org/zap" +) + +var ( + healthy int32 + ready int32 + watcher *fscache.Watcher +) + +type Config struct { + HttpClientTimeout time.Duration + HttpServerTimeout time.Duration + HttpServerShutdownTimeout time.Duration + BackendURL string + UIMessage string + UIColor string + UIPath string + DataPath string + ConfigPath string + Port string + Hostname string +} + +type Server struct { + router *mux.Router + logger *zap.Logger + config *Config +} + +func NewServer(config *Config, logger *zap.Logger) (*Server, error) { + srv := &Server{ + router: mux.NewRouter(), + logger: logger, + config: config, + } + + return srv, nil +} + +func (s *Server) registerHandlers() { + s.router.Handle("/metrics", promhttp.Handler()) + s.router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux) + s.router.HandleFunc("/", s.indexHandler).HeadersRegexp("User-Agent", "^Mozilla.*").Methods("GET") + s.router.HandleFunc("/", s.infoHandler).Methods("GET") + s.router.HandleFunc("/version", s.versionHandler).Methods("GET") + s.router.HandleFunc("/echo", s.echoHandler).Methods("POST") + s.router.HandleFunc("/headers", s.echoHeadersHandler).Methods("GET") + s.router.HandleFunc("/delay/{wait:[0-9]+}", s.delayHandler).Methods("GET") + s.router.HandleFunc("/healthz", s.healthzHandler).Methods("GET") + s.router.HandleFunc("/readyz", s.readyzHandler).Methods("GET") + s.router.HandleFunc("/readyz/enable", s.enableReadyHandler).Methods("POST") + s.router.HandleFunc("/readyz/disable", s.disableReadyHandler).Methods("POST") + s.router.HandleFunc("/panic", s.panicHandler).Methods("GET") + s.router.HandleFunc("/status/{code:[0-9]+}", s.statusHandler).Methods("GET", "POST", "PUT") + s.router.HandleFunc("/store", s.storeWriteHandler).Methods("POST") + s.router.HandleFunc("/store/{hash}", s.storeReadHandler).Methods("GET") + s.router.HandleFunc("/configs", s.configReadHandler).Methods("GET") + s.router.HandleFunc("/api/info", s.infoHandler).Methods("GET") + s.router.HandleFunc("/api/echo", s.echoHandler).Methods("POST") +} + +func (s *Server) registerMiddlewares() { + prom := NewPrometheusMiddleware() + s.router.Use(prom.Handler) + s.router.Use(versionMiddleware) +} + +func (s *Server) ListenAndServe(stopCh <-chan struct{}) { + + s.registerHandlers() + s.registerMiddlewares() + + srv := &http.Server{ + Addr: ":" + s.config.Port, + WriteTimeout: s.config.HttpServerTimeout, + ReadTimeout: s.config.HttpServerTimeout, + IdleTimeout: 2 * s.config.HttpServerTimeout, + Handler: s.router, + } + + //s.printRoutes() + + // load configs in memory and start watching for changes in the config dir + if len(s.config.ConfigPath) > 0 { + var err error + watcher, err = fscache.NewWatch(s.config.ConfigPath) + if err != nil { + s.logger.Error("config watch error", zap.Error(err), zap.String("path", s.config.ConfigPath)) + } else { + watcher.Watch() + } + } + + // run server in background + go func() { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + s.logger.Fatal("HTTP server crashed", zap.Error(err)) + } + }() + + // signal Kubernetes the server is ready to receive traffic + atomic.StoreInt32(&healthy, 1) + atomic.StoreInt32(&ready, 1) + + // wait for SIGTERM or SIGINT + <-stopCh + ctx, cancel := context.WithTimeout(context.Background(), s.config.HttpServerShutdownTimeout) + defer cancel() + + // all calls to /healthz and /readyz will fail from now on + atomic.StoreInt32(&healthy, 0) + atomic.StoreInt32(&ready, 0) + + s.logger.Info("Shutting down HTTP server", zap.Duration("timeout", s.config.HttpServerShutdownTimeout)) + + // wait for Kubernetes readiness probe + // to remove this instance from the load balancer + // the readiness check interval must lower than the timeout + //time.Sleep(s.config.HttpServerShutdownTimeout) + + // attempt graceful shutdown + if err := srv.Shutdown(ctx); err != nil { + s.logger.Warn("HTTP server graceful shutdown failed", zap.Error(err)) + } else { + s.logger.Info("HTTP server stopped") + } +} + +func (s *Server) printRoutes() { + s.router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { + pathTemplate, err := route.GetPathTemplate() + if err == nil { + fmt.Println("ROUTE:", pathTemplate) + } + pathRegexp, err := route.GetPathRegexp() + if err == nil { + fmt.Println("Path regexp:", pathRegexp) + } + queriesTemplates, err := route.GetQueriesTemplates() + if err == nil { + fmt.Println("Queries templates:", strings.Join(queriesTemplates, ",")) + } + queriesRegexps, err := route.GetQueriesRegexp() + if err == nil { + fmt.Println("Queries regexps:", strings.Join(queriesRegexps, ",")) + } + methods, err := route.GetMethods() + if err == nil { + fmt.Println("Methods:", strings.Join(methods, ",")) + } + fmt.Println() + return nil + }) +}