Skip to content

Commit

Permalink
feat: support for file based live configuration reloading
Browse files Browse the repository at this point in the history
A new optional flag (long: --watch-dir, short: -w) has been
added. When present any files with a ".env" suffix will be
loaded into the environment before the *GlobalConfiguration
is created, otherwise existing behavior is preserved.

In addition when the watch-dir flag is present a goroutine
will be started in serve_cmd.go and begin blocking on a call
to (*Reloader).Watch with a callback function that accepts a
*conf.GlobalConfiguration object. Each time this function is
called we create a new API object and store it within our
AtomicHandler, previously given as the root handler to the
*http.Server.

The Reloader uses some simple heuristics to deal with a few
edge cases, an overview:

  - At most 1 configuration reload may happen per 10 seconds
  with a +-1s margin of error.
  - After a file within -watch-dir has changed the 10 second
  grace period begins. After that it will reload the config.
  - Config reloads first sort each file by name then processes
  them in sequence.
  - Directories within watch-dir are ignored during config
  reloading.
    - Implementation quirk: directory changes can trigger
      a config reload, as I don't stat fsnotify events. This
	  and similar superfulous reloads could be easily fixed
	  by storing a snapshot of os.Environ() after successful
	  reloads to compare with the latest via slices.Equal()
	  before reloading.
  - Files that do not end with a .env suffix are ignored.
  - It handles the removal or renaming of the -watch-dir
    during runtime, but an error message will be printed
	every 10 seconds as long as it's missing.
  - The config file passed with -c is only loaded once. Live
    reloads only read the config dir. Meaning it would be
	possible to create a config dir change that results in a
	new final configuration on the next reload due to the
	persistence of os.Environ().
  • Loading branch information
Chris Stockton committed Sep 10, 2024
1 parent 428d0f0 commit 03ce1cf
Show file tree
Hide file tree
Showing 11 changed files with 634 additions and 81 deletions.
9 changes: 6 additions & 3 deletions cmd/root_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"github.com/supabase/auth/internal/observability"
)

var configFile = ""
var (
configFile = ""
watchDir = ""
)

var rootCmd = cobra.Command{
Use: "gotrue",
Expand All @@ -22,8 +25,8 @@ var rootCmd = cobra.Command{
// RootCommand will setup and return the root command
func RootCommand() *cobra.Command {
rootCmd.AddCommand(&serveCmd, &migrateCmd, &versionCmd, adminCmd())
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "the config file to use")

rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "base configuration file to load")
rootCmd.PersistentFlags().StringVarP(&watchDir, "watch-dir", "w", "", "directory containing a sorted list of config files to watch for changes")
return &rootCmd
}

Expand Down
74 changes: 70 additions & 4 deletions cmd/serve_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ package cmd
import (
"context"
"net"
"net/http"
"sync"
"time"

"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/supabase/auth/internal/api"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/reloader"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)
Expand All @@ -21,7 +26,15 @@ var serveCmd = cobra.Command{
}

func serve(ctx context.Context) {
config, err := conf.LoadGlobal(configFile)
if err := conf.LoadFile(configFile); err != nil {
logrus.WithError(err).Fatal("unable to load config")
}

if err := conf.LoadDirectory(watchDir); err != nil {
logrus.WithError(err).Fatal("unable to load config from watch dir")
}

config, err := conf.LoadGlobalFromEnv()
if err != nil {
logrus.WithError(err).Fatal("unable to load config")
}
Expand All @@ -32,10 +45,63 @@ func serve(ctx context.Context) {
}
defer db.Close()

api := api.NewAPIWithVersion(config, db, utilities.Version)

addr := net.JoinHostPort(config.API.Host, config.API.Port)
logrus.Infof("GoTrue API started on: %s", addr)

api.ListenAndServe(ctx, addr)
a := api.NewAPIWithVersion(config, db, utilities.Version)
ah := reloader.NewAtomicHandler(a)

baseCtx, baseCancel := context.WithCancel(context.Background())
defer baseCancel()

httpSrv := &http.Server{
Addr: addr,
Handler: ah,
ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
BaseContext: func(net.Listener) context.Context {
return baseCtx
},
}
log := logrus.WithField("component", "api")

var wg sync.WaitGroup
defer wg.Wait() // Do not return to caller until this goroutine is done.

if watchDir != "" {
wg.Add(1)
go func() {
defer wg.Done()

fn := func(latestCfg *conf.GlobalConfiguration) {
log.Info("reloading api with new configuration")
latestAPI := api.NewAPIWithVersion(latestCfg, db, utilities.Version)
ah.Store(latestAPI)
}

rl := reloader.NewReloader(watchDir)
if err := rl.Watch(ctx, fn); err != nil {
log.WithError(err).Error("watcher is exiting")
}
}()
}

wg.Add(1)
go func() {
defer wg.Done()

<-ctx.Done()

defer baseCancel() // close baseContext

shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute)
defer shutdownCancel()

if err := httpSrv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
log.WithError(err).Error("shutdown failed")
}
}()

if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
log.WithError(err).Fatal("http server listen failed")
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ require (
require (
github.com/bits-and-blooms/bitset v1.10.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/gobuffalo/nulls v0.4.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
Expand Down
6 changes: 6 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,9 @@ func (a *API) Mailer() mailer.Mailer {
config := a.config
return mailer.NewMailer(config)
}

// ServeHTTP implements the http.Handler interface by passing the request along
// to its underlying Handler.
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.handler.ServeHTTP(w, r)
}
18 changes: 0 additions & 18 deletions internal/api/cleanup.go

This file was deleted.

47 changes: 0 additions & 47 deletions internal/api/listener.go

This file was deleted.

141 changes: 141 additions & 0 deletions internal/reloader/reloader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Package reloader provides support for live configuration reloading.
package reloader

import (
"context"
"log"
"strings"
"time"

"github.com/fsnotify/fsnotify"
"github.com/sirupsen/logrus"
"github.com/supabase/auth/internal/conf"
)

const (
// reloadInterval is the interval between configuration reloading. At most
// one configuration change may be made between this duration.
reloadInterval = time.Second * 10

// tickerInterval is the maximum latency between configuration reloads.
tickerInterval = reloadInterval / 10
)

type ConfigFunc func(*conf.GlobalConfiguration)

type Reloader struct {
watchDir string
reloadIval time.Duration
tickerIval time.Duration
}

func NewReloader(watchDir string) *Reloader {
return &Reloader{
watchDir: watchDir,
reloadIval: reloadInterval,
tickerIval: tickerInterval,
}
}

// reload attempts to create a new *conf.GlobalConfiguration after loading the
// currently configured watchDir.
func (rl *Reloader) reload() (*conf.GlobalConfiguration, error) {
if err := conf.LoadDirectory(rl.watchDir); err != nil {
return nil, err
}

cfg, err := conf.LoadGlobalFromEnv()
if err != nil {
return nil, err
}
return cfg, nil
}

// reloadCheckAt checks if reloadConfig should be called, returns true if config
// should be reloaded or false otherwise.
func (rl *Reloader) reloadCheckAt(at, lastUpdate time.Time) bool {
if lastUpdate.IsZero() {
return false // no pending updates
}
if at.Sub(lastUpdate) < rl.reloadIval {
return false // waiting for reload interval
}

// Update is pending.
return true
}

func (rl *Reloader) Watch(ctx context.Context, fn ConfigFunc) error {
wr, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer wr.Close()

tr := time.NewTicker(rl.tickerIval)
defer tr.Stop()

// Ignore errors, if watch dir doesn't exist we can add it later.
if err := wr.Add(rl.watchDir); err != nil {
logrus.WithError(err).Error("watch dir failed")
}

var lastUpdate time.Time
for {
select {
case <-ctx.Done():
return ctx.Err()

case <-tr.C:
// This is a simple way to solve watch dir being added later or
// being moved and then recreated. I've tested all of these basic
// scenarios and wr.WatchList() does not grow which aligns with
// the documented behavior.
if err := wr.Add(rl.watchDir); err != nil {
logrus.WithError(err).Error("watch dir failed")
}

// Check to see if the config is ready to be relaoded.
if !rl.reloadCheckAt(time.Now(), lastUpdate) {
continue
}

// Reset the last update time before we try to reload the config.
lastUpdate = time.Time{}

cfg, err := rl.reload()
if err != nil {
logrus.WithError(err).Error("config reload failed")
continue
}

// Call the callback function with the latest cfg.
fn(cfg)

case evt, ok := <-wr.Events:
if !ok {
logrus.WithError(err).Error("fsnotify has exited")
return nil
}

// We only read files ending in .env
if !strings.HasSuffix(evt.Name, ".env") {
continue
}

switch {
case evt.Op.Has(fsnotify.Create),
evt.Op.Has(fsnotify.Remove),
evt.Op.Has(fsnotify.Rename),
evt.Op.Has(fsnotify.Write):
lastUpdate = time.Now()
}
case err, ok := <-wr.Errors:
if !ok {
logrus.Error("fsnotify has exited")
return nil
}
logrus.WithError(err).Error("fsnotify has reported an error")
}
}
}
Loading

0 comments on commit 03ce1cf

Please sign in to comment.