Skip to content

Commit adbb4ec

Browse files
cstocktonChris Stockton
authored andcommitted
feat: config reloading (#1771)
## What kind of change does this PR introduce? File based configuration reloading using fsnotify. ## What is the current behavior? Currently the Auth config is loaded once from the environment or file (-c flag) and persists until the service is restarted. ## What is the new behavior? 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.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()`. --------- Co-authored-by: Chris Stockton <[email protected]>
1 parent d56f239 commit adbb4ec

File tree

15 files changed

+880
-93
lines changed

15 files changed

+880
-93
lines changed

cmd/root_cmd.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import (
99
"github.com/supabase/auth/internal/observability"
1010
)
1111

12-
var configFile = ""
12+
var (
13+
configFile = ""
14+
watchDir = ""
15+
)
1316

1417
var rootCmd = cobra.Command{
1518
Use: "gotrue",
@@ -22,8 +25,8 @@ var rootCmd = cobra.Command{
2225
// RootCommand will setup and return the root command
2326
func RootCommand() *cobra.Command {
2427
rootCmd.AddCommand(&serveCmd, &migrateCmd, &versionCmd, adminCmd())
25-
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "the config file to use")
26-
28+
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "base configuration file to load")
29+
rootCmd.PersistentFlags().StringVarP(&watchDir, "config-dir", "d", "", "directory containing a sorted list of config files to watch for changes")
2730
return &rootCmd
2831
}
2932

cmd/serve_cmd.go

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ package cmd
33
import (
44
"context"
55
"net"
6+
"net/http"
7+
"sync"
8+
"time"
69

10+
"github.com/pkg/errors"
711
"github.com/sirupsen/logrus"
812
"github.com/spf13/cobra"
913
"github.com/supabase/auth/internal/api"
1014
"github.com/supabase/auth/internal/conf"
15+
"github.com/supabase/auth/internal/reloader"
1116
"github.com/supabase/auth/internal/storage"
1217
"github.com/supabase/auth/internal/utilities"
1318
)
@@ -21,7 +26,15 @@ var serveCmd = cobra.Command{
2126
}
2227

2328
func serve(ctx context.Context) {
24-
config, err := conf.LoadGlobal(configFile)
29+
if err := conf.LoadFile(configFile); err != nil {
30+
logrus.WithError(err).Fatal("unable to load config")
31+
}
32+
33+
if err := conf.LoadDirectory(watchDir); err != nil {
34+
logrus.WithError(err).Fatal("unable to load config from watch dir")
35+
}
36+
37+
config, err := conf.LoadGlobalFromEnv()
2538
if err != nil {
2639
logrus.WithError(err).Fatal("unable to load config")
2740
}
@@ -32,10 +45,63 @@ func serve(ctx context.Context) {
3245
}
3346
defer db.Close()
3447

35-
api := api.NewAPIWithVersion(config, db, utilities.Version)
36-
3748
addr := net.JoinHostPort(config.API.Host, config.API.Port)
3849
logrus.Infof("GoTrue API started on: %s", addr)
3950

40-
api.ListenAndServe(ctx, addr)
51+
a := api.NewAPIWithVersion(config, db, utilities.Version)
52+
ah := reloader.NewAtomicHandler(a)
53+
54+
baseCtx, baseCancel := context.WithCancel(context.Background())
55+
defer baseCancel()
56+
57+
httpSrv := &http.Server{
58+
Addr: addr,
59+
Handler: ah,
60+
ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
61+
BaseContext: func(net.Listener) context.Context {
62+
return baseCtx
63+
},
64+
}
65+
log := logrus.WithField("component", "api")
66+
67+
var wg sync.WaitGroup
68+
defer wg.Wait() // Do not return to caller until this goroutine is done.
69+
70+
if watchDir != "" {
71+
wg.Add(1)
72+
go func() {
73+
defer wg.Done()
74+
75+
fn := func(latestCfg *conf.GlobalConfiguration) {
76+
log.Info("reloading api with new configuration")
77+
latestAPI := api.NewAPIWithVersion(latestCfg, db, utilities.Version)
78+
ah.Store(latestAPI)
79+
}
80+
81+
rl := reloader.NewReloader(watchDir)
82+
if err := rl.Watch(ctx, fn); err != nil {
83+
log.WithError(err).Error("watcher is exiting")
84+
}
85+
}()
86+
}
87+
88+
wg.Add(1)
89+
go func() {
90+
defer wg.Done()
91+
92+
<-ctx.Done()
93+
94+
defer baseCancel() // close baseContext
95+
96+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute)
97+
defer shutdownCancel()
98+
99+
if err := httpSrv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
100+
log.WithError(err).Error("shutdown failed")
101+
}
102+
}()
103+
104+
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
105+
log.WithError(err).Fatal("http server listen failed")
106+
}
41107
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
require (
3737
github.com/bits-and-blooms/bitset v1.10.0 // indirect
3838
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
39+
github.com/fsnotify/fsnotify v1.7.0 // indirect
3940
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
4041
github.com/gobuffalo/nulls v0.4.2 // indirect
4142
github.com/goccy/go-json v0.10.3 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
6161
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
6262
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
6363
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
64+
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
65+
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
6466
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
6567
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
6668
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=

internal/api/api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,9 @@ func (a *API) Mailer() mailer.Mailer {
357357
config := a.config
358358
return mailer.NewMailer(config)
359359
}
360+
361+
// ServeHTTP implements the http.Handler interface by passing the request along
362+
// to its underlying Handler.
363+
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
364+
a.handler.ServeHTTP(w, r)
365+
}

internal/api/cleanup.go

Lines changed: 0 additions & 18 deletions
This file was deleted.

internal/api/listener.go

Lines changed: 0 additions & 47 deletions
This file was deleted.

internal/conf/configuration.go

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/url"
99
"os"
10+
"path/filepath"
1011
"regexp"
1112
"strings"
1213
"text/template"
@@ -647,59 +648,140 @@ func (e *ExtensibilityPointConfiguration) PopulateExtensibilityPoint() error {
647648
return nil
648649
}
649650

651+
// LoadFile calls godotenv.Load() when the given filename is empty ignoring any
652+
// errors loading, otherwise it calls godotenv.Overload(filename).
653+
//
654+
// godotenv.Load: preserves env, ".env" path is optional
655+
// godotenv.Overload: overrides env, "filename" path must exist
656+
func LoadFile(filename string) error {
657+
var err error
658+
if filename != "" {
659+
err = godotenv.Overload(filename)
660+
} else {
661+
err = godotenv.Load()
662+
// handle if .env file does not exist, this is OK
663+
if os.IsNotExist(err) {
664+
return nil
665+
}
666+
}
667+
return err
668+
}
669+
670+
// LoadDirectory does nothing when configDir is empty, otherwise it will attempt
671+
// to load a list of configuration files located in configDir by using ReadDir
672+
// to obtain a sorted list of files containing a .env suffix.
673+
//
674+
// When the list is empty it will do nothing, otherwise it passes the file list
675+
// to godotenv.Overload to pull them into the current environment.
676+
func LoadDirectory(configDir string) error {
677+
if configDir == "" {
678+
return nil
679+
}
680+
681+
// Returns entries sorted by filename
682+
ents, err := os.ReadDir(configDir)
683+
if err != nil {
684+
// We mimic the behavior of LoadGlobal here, if an explicit path is
685+
// provided we return an error.
686+
return err
687+
}
688+
689+
var paths []string
690+
for _, ent := range ents {
691+
if ent.IsDir() {
692+
continue // ignore directories
693+
}
694+
695+
// We only read files ending in .env
696+
name := ent.Name()
697+
if !strings.HasSuffix(name, ".env") {
698+
continue
699+
}
700+
701+
// ent.Name() does not include the watch dir.
702+
paths = append(paths, filepath.Join(configDir, name))
703+
}
704+
705+
// If at least one path was found we load the configuration files in the
706+
// directory. We don't call override without config files because it will
707+
// override the env vars previously set with a ".env", if one exists.
708+
if len(paths) > 0 {
709+
if err := godotenv.Overload(paths...); err != nil {
710+
return err
711+
}
712+
}
713+
return nil
714+
}
715+
716+
// LoadGlobalFromEnv will return a new *GlobalConfiguration value from the
717+
// currently configured environment.
718+
func LoadGlobalFromEnv() (*GlobalConfiguration, error) {
719+
config := new(GlobalConfiguration)
720+
if err := loadGlobal(config); err != nil {
721+
return nil, err
722+
}
723+
return config, nil
724+
}
725+
650726
func LoadGlobal(filename string) (*GlobalConfiguration, error) {
651727
if err := loadEnvironment(filename); err != nil {
652728
return nil, err
653729
}
654730

655731
config := new(GlobalConfiguration)
732+
if err := loadGlobal(config); err != nil {
733+
return nil, err
734+
}
735+
return config, nil
736+
}
656737

738+
func loadGlobal(config *GlobalConfiguration) error {
657739
// although the package is called "auth" it used to be called "gotrue"
658740
// so environment configs will remain to be called "GOTRUE"
659741
if err := envconfig.Process("gotrue", config); err != nil {
660-
return nil, err
742+
return err
661743
}
662744

663745
if err := config.ApplyDefaults(); err != nil {
664-
return nil, err
746+
return err
665747
}
666748

667749
if err := config.Validate(); err != nil {
668-
return nil, err
750+
return err
669751
}
670752

671753
if config.Hook.PasswordVerificationAttempt.Enabled {
672754
if err := config.Hook.PasswordVerificationAttempt.PopulateExtensibilityPoint(); err != nil {
673-
return nil, err
755+
return err
674756
}
675757
}
676758

677759
if config.Hook.SendSMS.Enabled {
678760
if err := config.Hook.SendSMS.PopulateExtensibilityPoint(); err != nil {
679-
return nil, err
761+
return err
680762
}
681763
}
682764
if config.Hook.SendEmail.Enabled {
683765
if err := config.Hook.SendEmail.PopulateExtensibilityPoint(); err != nil {
684-
return nil, err
766+
return err
685767
}
686768
}
687769

688770
if config.Hook.MFAVerificationAttempt.Enabled {
689771
if err := config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint(); err != nil {
690-
return nil, err
772+
return err
691773
}
692774
}
693775

694776
if config.Hook.CustomAccessToken.Enabled {
695777
if err := config.Hook.CustomAccessToken.PopulateExtensibilityPoint(); err != nil {
696-
return nil, err
778+
return err
697779
}
698780
}
699781

700782
if config.SAML.Enabled {
701783
if err := config.SAML.PopulateFields(config.API.ExternalURL); err != nil {
702-
return nil, err
784+
return err
703785
}
704786
} else {
705787
config.SAML.PrivateKey = ""
@@ -712,7 +794,7 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) {
712794
}
713795
template, err := template.New("").Parse(SMSTemplate)
714796
if err != nil {
715-
return nil, err
797+
return err
716798
}
717799
config.Sms.SMSTemplate = template
718800
}
@@ -724,12 +806,12 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) {
724806
}
725807
template, err := template.New("").Parse(smsTemplate)
726808
if err != nil {
727-
return nil, err
809+
return err
728810
}
729811
config.MFA.Phone.SMSTemplate = template
730812
}
731813

732-
return config, nil
814+
return nil
733815
}
734816

735817
// ApplyDefaults sets defaults for a GlobalConfiguration

0 commit comments

Comments
 (0)