package main import ( "context" "errors" "net" "net/http" "net/http/httputil" "net/url" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "git.ipao.vip/rogee/creator-hub/internal/phasea" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) type config struct { listenAddr string gateway *url.URL webDir string databaseURL string logLevel logrus.Level } func main() { logrus.SetFormatter(&logrus.JSONFormatter{}) if err := execute(newCommand()); err != nil { logrus.WithField("service", "control-plane").WithError(err).Error("service stopped") os.Exit(1) } } func execute(command *cobra.Command) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return command.ExecuteContext(ctx) } 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) store, err := phasea.Open(command.Context(), cfg.databaseURL) if err != nil { return err } defer store.Close() logStartup(cfg) return newHandlerWithPhaseA(cfg.gateway, cfg.webDir, store).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) }, } return command } func logStartup(cfg config) { logrus.WithFields(logrus.Fields{ "service": "control-plane", "listen_addr": cfg.listenAddr, "docker_gateway": cfg.gateway.Scheme + "://" + cfg.gateway.Host, }).Info("service starting") } 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("database_url", "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable") 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("database_url", "DATABASE_URL") _ = 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")), databaseURL: strings.TrimSpace(v.GetString("database_url")), 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") } databaseURL, err := url.Parse(cfg.databaseURL) if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" || (databaseURL.Scheme != "postgres" && databaseURL.Scheme != "postgresql") { return config{}, errors.New("DATABASE_URL must be a postgres URL with a host") } 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 { return newHandlerWithPhaseA(gateway, webDirectory, nil) } func newHandlerWithPhaseA(gateway *url.URL, webDirectory string, store *phasea.Store) *fiber.App { proxy := httputil.NewSingleHostReverseProxy(gateway) originalDirector := proxy.Director proxy.Director = func(request *http.Request) { originalDirector(request) request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers") request.Host = gateway.Host } proxyHandler := adaptor.HTTPHandler(proxy) app := fiber.New(fiber.Config{ AppName: "CreatorHub control plane", BodyLimit: 1 << 20, ReadTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, }) app.Get("/healthz", func(c fiber.Ctx) error { c.Status(fiber.StatusNoContent) return nil }) app.All("/api/browsers", proxyHandler) app.All("/api/browsers/*", proxyHandler) if store != nil { registerPhaseA(app, store) } app.Get("/*", spaHandler(webDirectory)) return app } func spaHandler(directory string) fiber.Handler { return func(c fiber.Ctx) error { path, err := url.PathUnescape(strings.TrimPrefix(c.Path(), "/")) path = filepath.Clean(path) if err == nil && filepath.IsLocal(path) { name := filepath.Join(directory, path) if info, err := os.Stat(name); err == nil && !info.IsDir() { return c.SendFile(name) } } return c.SendFile(filepath.Join(directory, "index.html")) } }