HH-744: converge Go service infrastructure (#6)
This commit is contained in:
+121
-37
@@ -1,32 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gateway, err := url.Parse(env("DOCKER_GATEWAY_URL", "http://127.0.0.1:8081"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: env("LISTEN_ADDR", ":8080"),
|
||||
Handler: newHandler(gateway, env("WEB_DIR", "web/dist")),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Printf("CreatorHub control plane listening on %s", server.Addr)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
type config struct {
|
||||
listenAddr string
|
||||
gateway *url.URL
|
||||
webDir string
|
||||
logLevel logrus.Level
|
||||
}
|
||||
|
||||
func newHandler(gateway *url.URL, webDirectory string) http.Handler {
|
||||
func main() {
|
||||
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||
if err := newCommand().Execute(); err != nil {
|
||||
logrus.WithField("service", "control-plane").WithError(err).Error("service stopped")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newCommand() *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "control-plane",
|
||||
Short: "Run the CreatorHub control plane",
|
||||
Args: cobra.NoArgs,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, _ []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.SetLevel(cfg.logLevel)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"service": "control-plane",
|
||||
"listen_addr": cfg.listenAddr,
|
||||
"docker_gateway": cfg.gateway.String(),
|
||||
}).Info("service starting")
|
||||
return newHandler(cfg.gateway, cfg.webDir).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||
GracefulContext: command.Context(),
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
v := viper.New()
|
||||
v.SetDefault("listen_addr", ":8080")
|
||||
v.SetDefault("docker_gateway_url", "http://127.0.0.1:8081")
|
||||
v.SetDefault("web_dir", "web/dist")
|
||||
v.SetDefault("log_level", "info")
|
||||
_ = v.BindEnv("listen_addr", "LISTEN_ADDR")
|
||||
_ = v.BindEnv("docker_gateway_url", "DOCKER_GATEWAY_URL")
|
||||
_ = v.BindEnv("web_dir", "WEB_DIR")
|
||||
_ = v.BindEnv("log_level", "LOG_LEVEL")
|
||||
|
||||
gateway, err := url.Parse(v.GetString("docker_gateway_url"))
|
||||
if err != nil || gateway.Host == "" || (gateway.Scheme != "http" && gateway.Scheme != "https") {
|
||||
return config{}, errors.New("DOCKER_GATEWAY_URL must be an http(s) URL with a host")
|
||||
}
|
||||
level, err := logrus.ParseLevel(v.GetString("log_level"))
|
||||
if err != nil {
|
||||
return config{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace")
|
||||
}
|
||||
cfg := config{
|
||||
listenAddr: strings.TrimSpace(v.GetString("listen_addr")),
|
||||
gateway: gateway,
|
||||
webDir: strings.TrimSpace(v.GetString("web_dir")),
|
||||
logLevel: level,
|
||||
}
|
||||
if cfg.listenAddr == "" {
|
||||
return config{}, errors.New("LISTEN_ADDR must not be empty")
|
||||
}
|
||||
if err := validateListenAddr(cfg.listenAddr); err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
if cfg.webDir == "" {
|
||||
return config{}, errors.New("WEB_DIR must not be empty")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateListenAddr(addr string) error {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return errors.New("LISTEN_ADDR must be a host:port address")
|
||||
}
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number < 1 || number > 65535 {
|
||||
return errors.New("LISTEN_ADDR port must be 1..65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(gateway *url.URL, webDirectory string) *fiber.App {
|
||||
proxy := httputil.NewSingleHostReverseProxy(gateway)
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(request *http.Request) {
|
||||
@@ -34,32 +118,32 @@ func newHandler(gateway *url.URL, webDirectory string) http.Handler {
|
||||
request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers")
|
||||
request.Host = gateway.Host
|
||||
}
|
||||
proxyHandler := adaptor.HTTPHandler(proxy)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/browsers", proxy)
|
||||
mux.Handle("/api/browsers/", proxy)
|
||||
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "CreatorHub control plane",
|
||||
ReadTimeout: 5 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
})
|
||||
mux.Handle("/", spaHandler(webDirectory))
|
||||
return mux
|
||||
app.Get("/healthz", func(c fiber.Ctx) error {
|
||||
c.Status(fiber.StatusNoContent)
|
||||
return nil
|
||||
})
|
||||
app.All("/api/browsers", proxyHandler)
|
||||
app.All("/api/browsers/*", proxyHandler)
|
||||
app.Get("/*", spaHandler(webDirectory))
|
||||
return app
|
||||
}
|
||||
|
||||
func spaHandler(directory string) http.Handler {
|
||||
files := http.FileServer(http.Dir(directory))
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
name := filepath.Join(directory, filepath.Clean(strings.TrimPrefix(request.URL.Path, "/")))
|
||||
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
||||
files.ServeHTTP(response, request)
|
||||
return
|
||||
func spaHandler(directory string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
path := filepath.Clean(strings.TrimPrefix(c.Path(), "/"))
|
||||
if filepath.IsLocal(path) {
|
||||
name := filepath.Join(directory, path)
|
||||
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
||||
return c.SendFile(name)
|
||||
}
|
||||
}
|
||||
http.ServeFile(response, request, filepath.Join(directory, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func env(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
return c.SendFile(filepath.Join(directory, "index.html"))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,15 +22,47 @@ func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start", nil)
|
||||
response := httptest.NewRecorder()
|
||||
newHandler(upstream, t.TempDir()).ServeHTTP(response, request)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start?force=true", nil)
|
||||
response, err := newHandler(upstream, t.TempDir()).Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
proxied := <-requests
|
||||
if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" {
|
||||
t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.Path)
|
||||
if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" || proxied.URL.RawQuery != "force=true" {
|
||||
t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.String())
|
||||
}
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected upstream status 503, got %d", response.Code)
|
||||
if response.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected upstream status 503, got %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsInvalidGateway(t *testing.T) {
|
||||
t.Setenv("DOCKER_GATEWAY_URL", "file:///var/run/docker.sock")
|
||||
if _, err := loadConfig(); err == nil {
|
||||
t.Fatal("expected invalid gateway URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAServesFileAndIndexFallback(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "app.js"), []byte("asset"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newHandler(&url.URL{Scheme: "http", Host: "127.0.0.1"}, directory)
|
||||
for path, want := range map[string]string{"/app.js": "asset", "/dashboard": "index"} {
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != want {
|
||||
t.Fatalf("GET %s: status=%d body=%q err=%v", path, response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user