diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2aa455..016a8a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: release: uses: itzg/github-workflows/.github/workflows/go-with-releaser-image.yml@main with: - go-version: "1.24.4" + go-version-file: 'go.mod' enable-ghcr: true secrets: image-registry-username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca25f32..3606806 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,4 +12,4 @@ jobs: build: uses: itzg/github-workflows/.github/workflows/go-test.yml@main with: - go-version: "1.24.4" + go-version-file: 'go.mod' diff --git a/README.md b/README.md index c991943..ef15ff2 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ The following shows a JSON file for routes config, where `default-server` can al } ``` -Sending a SIGHUP signal will cause mc-router to reload the routes config from disk. +Sending a SIGHUP signal will cause mc-router to reload the routes config from disk. The file can also be watched for changes by setting `-routes-config-watch` or the env variable `ROUTES_CONFIG_WATCH` to "true". ## Auto Scale Allow/Deny List diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go index 3624e62..d2a617a 100644 --- a/cmd/mc-router/main.go +++ b/cmd/mc-router/main.go @@ -16,18 +16,6 @@ import ( "github.com/sirupsen/logrus" ) -type MetricsBackendConfig struct { - Influxdb struct { - Interval time.Duration `default:"1m"` - Tags map[string]string `usage:"any extra tags to be included with all reported metrics"` - Addr string - Username string - Password string - Database string - RetentionPolicy string - } -} - type WebhookConfig struct { Url string `usage:"If set, a POST request that contains connection status notifications will be sent to this HTTP address"` RequireUser bool `default:"false" usage:"Indicates if the webhook will only be called if a user is connecting rather than just server list/ping"` @@ -62,11 +50,11 @@ type Config struct { DockerTimeout int `default:"0" usage:"Timeout configuration in seconds for the Docker integrations"` DockerRefreshInterval int `default:"15" usage:"Refresh interval in seconds for the Docker integrations"` MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb,prometheus"` - UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"` - ReceiveProxyProtocol bool `default:"false" usage:"Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies"` - TrustedProxies []string `usage:"Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol"` - RecordLogins bool `default:"false" usage:"Log and generate metrics on player logins. Metrics only supported with influxdb or prometheus backend"` - MetricsBackendConfig MetricsBackendConfig + MetricsBackendConfig server.MetricsBackendConfig + UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"` + ReceiveProxyProtocol bool `default:"false" usage:"Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies"` + TrustedProxies []string `usage:"Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol"` + RecordLogins bool `default:"false" usage:"Log and generate metrics on player logins. Metrics only supported with influxdb or prometheus backend"` Routes RoutesConfig NgrokToken string `usage:"If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable."` AutoScale AutoScale @@ -133,7 +121,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - metricsBuilder := NewMetricsBuilder(config.MetricsBackend, &config.MetricsBackendConfig) + metricsBuilder := server.NewMetricsBuilder(config.MetricsBackend, &config.MetricsBackendConfig) downScalerEnabled := config.AutoScale.Down && (config.InKubeCluster || config.KubeConfig != "") downScalerDelay, err := time.ParseDuration(config.AutoScale.DownAfter) @@ -147,13 +135,13 @@ func main() { signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) if config.Routes.Config != "" { - err := server.RoutesConfig.ReadRoutesConfig(config.Routes.Config) + err := server.RoutesConfigLoader.Load(config.Routes.Config) if err != nil { logrus.WithError(err).Fatal("Unable to load routes from config file") } if config.Routes.ConfigWatch { - err := server.RoutesConfig.WatchForChanges(ctx) + err := server.RoutesConfigLoader.WatchForChanges(ctx) if err != nil { logrus.WithError(err).Fatal("Unable to watch for changes") } @@ -258,7 +246,7 @@ func main() { case syscall.SIGHUP: if config.Routes.Config != "" { logrus.Info("Received SIGHUP, reloading routes config...") - if err := server.RoutesConfig.ReloadRoutesConfig(); err != nil { + if err := server.RoutesConfigLoader.Reload(); err != nil { logrus. WithError(err). WithField("routesConfig", config.Routes.Config). diff --git a/go.mod b/go.mod index 7c23f1b..939df14 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/itzg/mc-router -go 1.24.0 - -toolchain go1.24.4 +go 1.24.4 require ( github.com/fsnotify/fsnotify v1.9.0 diff --git a/server/api_server.go b/server/api_server.go index afd8ef5..a0f55b1 100644 --- a/server/api_server.go +++ b/server/api_server.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "expvar" "net/http" @@ -9,11 +10,12 @@ import ( "github.com/sirupsen/logrus" ) -var apiRoutes = mux.NewRouter() - func StartApiServer(apiBinding string) { logrus.WithField("binding", apiBinding).Info("Serving API requests") + var apiRoutes = mux.NewRouter() + registerApiRoutes(apiRoutes) + apiRoutes.Path("/vars").Handler(expvar.Handler()) apiRoutes.Path("/metrics").Handler(promhttp.Handler()) @@ -23,3 +25,84 @@ func StartApiServer(apiBinding string) { http.ListenAndServe(apiBinding, apiRoutes)).Error("API server failed") }() } + +func registerApiRoutes(apiRoutes *mux.Router) { + apiRoutes.Path("/routes").Methods("GET"). + HandlerFunc(routesListHandler) + apiRoutes.Path("/routes").Methods("POST"). + HandlerFunc(routesCreateHandler) + apiRoutes.Path("/defaultRoute").Methods("POST"). + HandlerFunc(routesSetDefault) + apiRoutes.Path("/routes/{serverAddress}").Methods("DELETE").HandlerFunc(routesDeleteHandler) +} + +func routesListHandler(writer http.ResponseWriter, _ *http.Request) { + mappings := Routes.GetMappings() + bytes, err := json.Marshal(mappings) + if err != nil { + logrus.WithError(err).Error("Failed to marshal mappings") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + writer.Header().Set("Content-Type", "application/json") + _, err = writer.Write(bytes) + if err != nil { + logrus.WithError(err).Error("Failed to write response") + } +} + +func routesDeleteHandler(writer http.ResponseWriter, request *http.Request) { + serverAddress := mux.Vars(request)["serverAddress"] + if serverAddress != "" { + if Routes.DeleteMapping(serverAddress) { + writer.WriteHeader(http.StatusOK) + } else { + writer.WriteHeader(http.StatusNotFound) + } + RoutesConfigLoader.SaveRoutes() + } +} + +func routesCreateHandler(writer http.ResponseWriter, request *http.Request) { + var definition = struct { + ServerAddress string + Backend string + }{} + + //goland:noinspection GoUnhandledErrorResult + defer request.Body.Close() + + decoder := json.NewDecoder(request.Body) + err := decoder.Decode(&definition) + if err != nil { + logrus.WithError(err).Error("Unable to get request body") + writer.WriteHeader(http.StatusBadRequest) + return + } + + Routes.CreateMapping(definition.ServerAddress, definition.Backend, EmptyScalerFunc, EmptyScalerFunc) + RoutesConfigLoader.SaveRoutes() + writer.WriteHeader(http.StatusCreated) +} + +func routesSetDefault(writer http.ResponseWriter, request *http.Request) { + var body = struct { + Backend string + }{} + + //goland:noinspection GoUnhandledErrorResult + defer request.Body.Close() + + decoder := json.NewDecoder(request.Body) + err := decoder.Decode(&body) + if err != nil { + logrus.WithError(err).Error("Unable to parse request") + writer.WriteHeader(http.StatusBadRequest) + return + } + + Routes.SetDefaultRoute(body.Backend) + RoutesConfigLoader.SaveRoutes() + writer.WriteHeader(http.StatusOK) +} diff --git a/cmd/mc-router/metrics.go b/server/metrics.go similarity index 89% rename from cmd/mc-router/metrics.go rename to server/metrics.go index dbb5936..9da6dc5 100644 --- a/cmd/mc-router/metrics.go +++ b/server/metrics.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" @@ -13,14 +13,13 @@ import ( kitinflux "github.com/go-kit/kit/metrics/influx" prometheusMetrics "github.com/go-kit/kit/metrics/prometheus" influx "github.com/influxdata/influxdb1-client/v2" - "github.com/itzg/mc-router/server" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/sirupsen/logrus" ) type MetricsBuilder interface { - BuildConnectorMetrics() *server.ConnectorMetrics + BuildConnectorMetrics() *ConnectorMetrics Start(ctx context.Context) error } @@ -31,6 +30,18 @@ const ( MetricsBackendDiscard = "discard" ) +type MetricsBackendConfig struct { + Influxdb struct { + Interval time.Duration `default:"1m"` + Tags map[string]string `usage:"any extra tags to be included with all reported metrics"` + Addr string + Username string + Password string + Database string + RetentionPolicy string + } +} + // NewMetricsBuilder creates a new MetricsBuilder based on the specified backend. // If the backend is not recognized, a discard builder is returned. // config can be nil if the backend is not influxdb. @@ -57,9 +68,9 @@ func (b expvarMetricsBuilder) Start(ctx context.Context) error { return nil } -func (b expvarMetricsBuilder) BuildConnectorMetrics() *server.ConnectorMetrics { +func (b expvarMetricsBuilder) BuildConnectorMetrics() *ConnectorMetrics { c := expvarMetrics.NewCounter("connections") - return &server.ConnectorMetrics{ + return &ConnectorMetrics{ Errors: expvarMetrics.NewCounter("errors").With("subsystem", "connector"), BytesTransmitted: expvarMetrics.NewCounter("bytes"), ConnectionsFrontend: c, @@ -79,8 +90,8 @@ func (b discardMetricsBuilder) Start(ctx context.Context) error { return nil } -func (b discardMetricsBuilder) BuildConnectorMetrics() *server.ConnectorMetrics { - return &server.ConnectorMetrics{ +func (b discardMetricsBuilder) BuildConnectorMetrics() *ConnectorMetrics { + return &ConnectorMetrics{ Errors: discardMetrics.NewCounter(), BytesTransmitted: discardMetrics.NewCounter(), ConnectionsFrontend: discardMetrics.NewCounter(), @@ -121,7 +132,7 @@ func (b *influxMetricsBuilder) Start(ctx context.Context) error { return nil } -func (b *influxMetricsBuilder) BuildConnectorMetrics() *server.ConnectorMetrics { +func (b *influxMetricsBuilder) BuildConnectorMetrics() *ConnectorMetrics { influxConfig := &b.config.Influxdb metrics := kitinflux.New(influxConfig.Tags, influx.BatchPointsConfig{ @@ -132,7 +143,7 @@ func (b *influxMetricsBuilder) BuildConnectorMetrics() *server.ConnectorMetrics b.metrics = metrics c := metrics.NewCounter("mc_router_connections") - return &server.ConnectorMetrics{ + return &ConnectorMetrics{ Errors: metrics.NewCounter("mc_router_errors"), BytesTransmitted: metrics.NewCounter("mc_router_transmitted_bytes"), ConnectionsFrontend: c.With("side", "frontend"), @@ -155,13 +166,13 @@ func (b prometheusMetricsBuilder) Start(ctx context.Context) error { return nil } -func (b prometheusMetricsBuilder) BuildConnectorMetrics() *server.ConnectorMetrics { +func (b prometheusMetricsBuilder) BuildConnectorMetrics() *ConnectorMetrics { pcv = prometheusMetrics.NewCounter(promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "mc_router", Name: "errors", Help: "The total number of errors", }, []string{"type"})) - return &server.ConnectorMetrics{ + return &ConnectorMetrics{ Errors: pcv, BytesTransmitted: prometheusMetrics.NewCounter(promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "mc_router", diff --git a/server/routes.go b/server/routes.go index f69425b..7c2f6d7 100644 --- a/server/routes.go +++ b/server/routes.go @@ -2,13 +2,10 @@ package server import ( "context" - "encoding/json" - "net/http" "regexp" "strings" "sync" - "github.com/gorilla/mux" "github.com/sirupsen/logrus" ) @@ -18,88 +15,6 @@ var EmptyScalerFunc = func(ctx context.Context) error { return nil } var tcpShieldPattern = regexp.MustCompile("///.*") -func init() { - apiRoutes.Path("/routes").Methods("GET"). - Headers("Accept", "application/json"). - HandlerFunc(routesListHandler) - apiRoutes.Path("/routes").Methods("POST"). - Headers("Content-Type", "application/json"). - HandlerFunc(routesCreateHandler) - apiRoutes.Path("/defaultRoute").Methods("POST"). - Headers("Content-Type", "application/json"). - HandlerFunc(routesSetDefault) - apiRoutes.Path("/routes/{serverAddress}").Methods("DELETE").HandlerFunc(routesDeleteHandler) -} - -func routesListHandler(writer http.ResponseWriter, _ *http.Request) { - mappings := Routes.GetMappings() - bytes, err := json.Marshal(mappings) - if err != nil { - logrus.WithError(err).Error("Failed to marshal mappings") - writer.WriteHeader(http.StatusInternalServerError) - return - } - _, err = writer.Write(bytes) - if err != nil { - logrus.WithError(err).Error("Failed to write response") - } -} - -func routesDeleteHandler(writer http.ResponseWriter, request *http.Request) { - serverAddress := mux.Vars(request)["serverAddress"] - RoutesConfig.DeleteMapping(serverAddress) - if serverAddress != "" { - if Routes.DeleteMapping(serverAddress) { - writer.WriteHeader(http.StatusOK) - } else { - writer.WriteHeader(http.StatusNotFound) - } - } -} - -func routesCreateHandler(writer http.ResponseWriter, request *http.Request) { - var definition = struct { - ServerAddress string - Backend string - }{} - - //goland:noinspection GoUnhandledErrorResult - defer request.Body.Close() - - decoder := json.NewDecoder(request.Body) - err := decoder.Decode(&definition) - if err != nil { - logrus.WithError(err).Error("Unable to get request body") - writer.WriteHeader(http.StatusBadRequest) - return - } - - Routes.CreateMapping(definition.ServerAddress, definition.Backend, EmptyScalerFunc, EmptyScalerFunc) - RoutesConfig.AddMapping(definition.ServerAddress, definition.Backend) - writer.WriteHeader(http.StatusCreated) -} - -func routesSetDefault(writer http.ResponseWriter, request *http.Request) { - var body = struct { - Backend string - }{} - - //goland:noinspection GoUnhandledErrorResult - defer request.Body.Close() - - decoder := json.NewDecoder(request.Body) - err := decoder.Decode(&body) - if err != nil { - logrus.WithError(err).Error("Unable to parse request") - writer.WriteHeader(http.StatusBadRequest) - return - } - - Routes.SetDefaultRoute(body.Backend) - RoutesConfig.SetDefaultRoute(body.Backend) - writer.WriteHeader(http.StatusOK) -} - type IRoutes interface { Reset() RegisterAll(mappings map[string]string) @@ -112,6 +27,7 @@ type IRoutes interface { DeleteMapping(serverAddress string) bool CreateMapping(serverAddress string, backend string, waker ScalerFunc, sleeper ScalerFunc) SetDefaultRoute(backend string) + GetDefaultRoute() string SimplifySRV(srvEnabled bool) } @@ -157,6 +73,10 @@ func (r *routesImpl) SetDefaultRoute(backend string) { }).Info("Using default route") } +func (r *routesImpl) GetDefaultRoute() string { + return r.defaultRoute +} + func (r *routesImpl) SimplifySRV(srvEnabled bool) { r.simplifySRV = srvEnabled } diff --git a/server/routes_config.go b/server/routes_config.go deleted file mode 100644 index ac074a5..0000000 --- a/server/routes_config.go +++ /dev/null @@ -1,260 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - - "io/fs" - "os" - "sync" -) - -type IRoutesConfig interface { - ReadRoutesConfig(routesConfig string) - ReloadRoutesConfig() - AddMapping(serverAddress string, backend string) - DeleteMapping(serverAddress string) - SetDefaultRoute(backend string) - WatchForChanges(ctx context.Context) error -} - -const debounceConfigRereadDuration = time.Second * 5 - -var RoutesConfig = &routesConfigImpl{} - -type routesConfigImpl struct { - sync.RWMutex - fileName string -} - -type routesConfigStructure struct { - DefaultServer string `json:"default-server"` - Mappings map[string]string `json:"mappings"` -} - -func (r *routesConfigImpl) ReadRoutesConfig(routesConfig string) error { - r.fileName = routesConfig - - logrus.WithField("routesConfig", r.fileName).Info("Loading routes config file") - - config, readErr := r.readRoutesConfigFile() - - if readErr != nil { - if errors.Is(readErr, fs.ErrNotExist) { - logrus.WithField("routesConfig", r.fileName).Info("Routes config file doses not exist, skipping reading it") - // File doesn't exist -> ignore it - return nil - } - return errors.Wrap(readErr, "Could not load the routes config file") - } - - Routes.RegisterAll(config.Mappings) - Routes.SetDefaultRoute(config.DefaultServer) - return nil -} - -func (r *routesConfigImpl) ReloadRoutesConfig() error { - config, readErr := r.readRoutesConfigFile() - - if readErr != nil { - return readErr - } - - logrus.WithField("routesConfig", r.fileName).Info("Re-loading routes config file") - Routes.Reset() - Routes.RegisterAll(config.Mappings) - Routes.SetDefaultRoute(config.DefaultServer) - - return nil -} - -func (r *routesConfigImpl) WatchForChanges(ctx context.Context) error { - if r.fileName == "" { - return errors.New("routes config file needs to be specified first") - } - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return errors.Wrap(err, "Could not create a watcher") - } - - err = watcher.Add(r.fileName) - if err != nil { - return errors.Wrap(err, "Could not watch the routes config file") - } - - go func() { - logrus.WithField("file", r.fileName).Info("Watching routes config file") - - debounceTimerChan := make(<-chan time.Time) - var debounceTimer *time.Timer - - //goland:noinspection GoUnhandledErrorResult - defer watcher.Close() - for { - select { - - case event, ok := <-watcher.Events: - if !ok { - logrus.Debug("Watcher events channel closed") - return - } - logrus. - WithField("file", event.Name). - WithField("op", event.Op). - Trace("fs event received") - if event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Create) { - if debounceTimer == nil { - debounceTimer = time.NewTimer(debounceConfigRereadDuration) - } else { - debounceTimer.Reset(debounceConfigRereadDuration) - } - debounceTimerChan = debounceTimer.C - logrus.WithField("delay", debounceConfigRereadDuration).Debug("Will re-read config file after delay") - } - - case <-debounceTimerChan: - readErr := r.ReadRoutesConfig(r.fileName) - if readErr != nil { - logrus. - WithError(readErr). - WithField("routesConfig", r.fileName). - Error("Could not re-read the routes config file") - } - - case <-ctx.Done(): - return - } - } - }() - - return nil -} - -func (r *routesConfigImpl) AddMapping(serverAddress string, backend string) { - if !r.isRoutesConfigEnabled() { - return - } - - config, readErr := r.readRoutesConfigFile() - if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) { - logrus.WithError(readErr).Error("Could not read the routes config file") - return - } - if config.Mappings == nil { - config.Mappings = make(map[string]string) - } - - config.Mappings[serverAddress] = backend - - writeErr := r.writeRoutesConfigFile(config) - if writeErr != nil { - logrus.WithError(writeErr).Error("Could not write to the routes config file") - return - } - - logrus.WithFields(logrus.Fields{ - "serverAddress": serverAddress, - "backend": backend, - }).Info("Added route to routes config") - - return -} - -func (r *routesConfigImpl) SetDefaultRoute(backend string) { - if !r.isRoutesConfigEnabled() { - return - } - - config, readErr := r.readRoutesConfigFile() - if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) { - logrus.WithError(readErr).Error("Could not read the routes config file") - return - } - - config.DefaultServer = backend - - writeErr := r.writeRoutesConfigFile(config) - if writeErr != nil { - logrus.WithError(writeErr).Error("Could not write to the routes config file") - return - } - - logrus.WithFields(logrus.Fields{ - "backend": backend, - }).Info("Set default route in routes config") - - return -} - -func (r *routesConfigImpl) DeleteMapping(serverAddress string) { - if !r.isRoutesConfigEnabled() { - return - } - - config, readErr := r.readRoutesConfigFile() - if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) { - logrus.WithError(readErr).Error("Could not read the routes config file") - return - } - - delete(config.Mappings, serverAddress) - - writeErr := r.writeRoutesConfigFile(config) - if writeErr != nil { - logrus.WithError(writeErr).Error("Could not write to the routes config file") - return - } - - logrus.WithField("serverAddress", serverAddress).Info("Deleted route in routes config") - - return -} - -func (r *routesConfigImpl) isRoutesConfigEnabled() bool { - return r.fileName != "" -} - -func (r *routesConfigImpl) readRoutesConfigFile() (routesConfigStructure, error) { - r.RLock() - defer r.RUnlock() - - config := routesConfigStructure{ - "", - make(map[string]string), - } - - file, fileErr := os.ReadFile(r.fileName) - if fileErr != nil { - return config, errors.Wrap(fileErr, "Could not load the routes config file") - } - - parseErr := json.Unmarshal(file, &config) - if parseErr != nil { - return config, errors.Wrap(parseErr, "Could not parse the json routes config file") - } - - return config, nil -} - -func (r *routesConfigImpl) writeRoutesConfigFile(config routesConfigStructure) error { - r.Lock() - defer r.Unlock() - - newFileContent, parseErr := json.Marshal(config) - if parseErr != nil { - return errors.Wrap(parseErr, "Could not parse the routes to json") - } - - fileErr := os.WriteFile(r.fileName, newFileContent, 0664) - if fileErr != nil { - return errors.Wrap(fileErr, "Could not write to the routes config file") - } - - return nil -} diff --git a/server/routes_config_loader.go b/server/routes_config_loader.go new file mode 100644 index 0000000..8f88475 --- /dev/null +++ b/server/routes_config_loader.go @@ -0,0 +1,177 @@ +package server + +import ( + "context" + "encoding/json" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "io/fs" + "os" +) + +const debounceConfigRereadDuration = time.Second * 5 + +var RoutesConfigLoader = &routesConfigLoader{} + +type routesConfigLoader struct { + fileName string +} + +// RoutesConfigSchema declares the schema of the json file that can provide routes to serve +type RoutesConfigSchema struct { + DefaultServer string `json:"default-server"` + Mappings map[string]string `json:"mappings"` +} + +func (r *routesConfigLoader) Load(routesConfigFileName string) error { + r.fileName = routesConfigFileName + + logrus.WithField("routesConfigFileName", r.fileName).Info("Loading routes config file") + + config, readErr := r.readFile() + + if readErr != nil { + if errors.Is(readErr, fs.ErrNotExist) { + logrus.WithField("routesConfigFileName", r.fileName).Info("Routes config file doses not exist, skipping reading it") + // File doesn't exist -> ignore it + return nil + } + return errors.Wrap(readErr, "Could not load the routes config file") + } + + Routes.RegisterAll(config.Mappings) + Routes.SetDefaultRoute(config.DefaultServer) + return nil +} + +func (r *routesConfigLoader) Reload() error { + config, readErr := r.readFile() + + if readErr != nil { + return readErr + } + + logrus.WithField("routesConfig", r.fileName).Info("Re-loading routes config file") + Routes.Reset() + Routes.RegisterAll(config.Mappings) + Routes.SetDefaultRoute(config.DefaultServer) + + return nil +} + +func (r *routesConfigLoader) WatchForChanges(ctx context.Context) error { + if r.fileName == "" { + return errors.New("routes config file needs to be specified first") + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return errors.Wrap(err, "Could not create a watcher") + } + + err = watcher.Add(r.fileName) + if err != nil { + return errors.Wrap(err, "Could not watch the routes config file") + } + + go func() { + logrus.WithField("file", r.fileName).Info("Watching routes config file") + + debounceTimerChan := make(<-chan time.Time) + var debounceTimer *time.Timer + + //goland:noinspection GoUnhandledErrorResult + defer watcher.Close() + for { + select { + + case event, ok := <-watcher.Events: + if !ok { + logrus.Debug("Watcher events channel closed") + return + } + logrus. + WithField("file", event.Name). + WithField("op", event.Op). + Trace("fs event received") + if event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Create) { + if debounceTimer == nil { + debounceTimer = time.NewTimer(debounceConfigRereadDuration) + } else { + debounceTimer.Reset(debounceConfigRereadDuration) + } + debounceTimerChan = debounceTimer.C + logrus.WithField("delay", debounceConfigRereadDuration).Debug("Will re-read config file after delay") + } + + case <-debounceTimerChan: + readErr := r.Load(r.fileName) + if readErr != nil { + logrus. + WithError(readErr). + WithField("routesConfig", r.fileName). + Error("Could not re-read the routes config file") + } + + case <-ctx.Done(): + return + } + } + }() + + return nil +} + +func (r *routesConfigLoader) SaveRoutes() { + if !r.isEnabled() { + return + } + + err := r.writeFile(&RoutesConfigSchema{ + DefaultServer: Routes.GetDefaultRoute(), + Mappings: Routes.GetMappings(), + }) + if err != nil { + logrus.WithError(err).Error("Could not save the routes config file") + return + } + logrus.Info("Saved routes config") +} + +func (r *routesConfigLoader) isEnabled() bool { + return r.fileName != "" +} + +func (r *routesConfigLoader) readFile() (*RoutesConfigSchema, error) { + var config RoutesConfigSchema + + content, err := os.ReadFile(r.fileName) + if err != nil { + return &config, errors.Wrap(err, "Could not load the routes config file") + } + + parseErr := json.Unmarshal(content, &config) + if parseErr != nil { + return &config, errors.Wrap(parseErr, "Could not parse the json routes config file") + } + + return &config, nil +} + +func (r *routesConfigLoader) writeFile(config *RoutesConfigSchema) error { + newFileContent, err := json.Marshal(config) + if err != nil { + return errors.Wrap(err, "Could not parse the routes to json") + } + + err = os.WriteFile(r.fileName, newFileContent, 0664) + if err != nil { + return errors.Wrap(err, "Could not write to the routes config file") + } + + return nil +}