Skip to content

Commit ebc5039

Browse files
committed
feat(storage): add remote_startup_fetch_policy configuration option
Add configurable policy for handling remote git fetch failures during startup. This allows Flipt to continue operating with stale local data when the remote repository is temporarily unavailable. Signed-off-by: Roman Dmytrenko <rdmytrenko@gmail.com>
1 parent d3367b1 commit ebc5039

File tree

7 files changed

+287
-59
lines changed

7 files changed

+287
-59
lines changed

config/flipt.schema.cue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ JsonPath: string
200200

201201
#storage: [string]: {
202202
remote?: string
203+
remote_startup_policy?: *"required" | "optional"
203204
backend?: {
204205
type: *"memory" | "local"
205206
path?: string

config/flipt.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,10 @@
777777
"remote": {
778778
"type": "string"
779779
},
780+
"remoteStartupPolicy": {
781+
"type": "string",
782+
"enum": ["required", "optional"]
783+
},
780784
"backend": {
781785
"type": [
782786
"object",

internal/config/storage.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,16 @@ type StorageBackendConfig struct {
8080
// StorageConfig contains fields which will configure the type of backend in which Flipt will serve
8181
// flag state.
8282
type StorageConfig struct {
83-
Remote string `json:"remote,omitempty" mapstructure:"remote" yaml:"remote,omitempty"`
84-
Backend StorageBackendConfig `json:"backend,omitempty" mapstructure:"backend" yaml:"backend,omitempty"`
85-
Branch string `json:"branch,omitempty" mapstructure:"branch" yaml:"branch,omitempty"`
86-
CaCertBytes string `json:"-" mapstructure:"ca_cert_bytes" yaml:"-"`
87-
CaCertPath string `json:"-" mapstructure:"ca_cert_path" yaml:"-"`
88-
PollInterval time.Duration `json:"pollInterval,omitempty" mapstructure:"poll_interval" yaml:"poll_interval,omitempty"`
89-
InsecureSkipTLS bool `json:"-" mapstructure:"insecure_skip_tls" yaml:"-"`
90-
Credentials string `json:"-" mapstructure:"credentials" yaml:"-"`
91-
Signature SignatureConfig `json:"signature,omitempty" mapstructure:"signature,omitempty" yaml:"signature,omitempty"`
83+
Remote string `json:"remote,omitempty" mapstructure:"remote" yaml:"remote,omitempty"`
84+
RemoteStartupFetchPolicy string `json:"remote_startup_fetch_policy,omitempty" mapstructure:"remote_startup_fetch_policy" yaml:"remote_startup_fetch_policy,omitempty"`
85+
Backend StorageBackendConfig `json:"backend,omitempty" mapstructure:"backend" yaml:"backend,omitempty"`
86+
Branch string `json:"branch,omitempty" mapstructure:"branch" yaml:"branch,omitempty"`
87+
CaCertBytes string `json:"-" mapstructure:"ca_cert_bytes" yaml:"-"`
88+
CaCertPath string `json:"-" mapstructure:"ca_cert_path" yaml:"-"`
89+
PollInterval time.Duration `json:"pollInterval,omitempty" mapstructure:"poll_interval" yaml:"poll_interval,omitempty"`
90+
InsecureSkipTLS bool `json:"-" mapstructure:"insecure_skip_tls" yaml:"-"`
91+
Credentials string `json:"-" mapstructure:"credentials" yaml:"-"`
92+
Signature SignatureConfig `json:"signature,omitempty" mapstructure:"signature,omitempty" yaml:"signature,omitempty"`
9293
}
9394

9495
func (c *StorageConfig) validate() error {

internal/storage/environments/environments.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ func (rm *RepositoryManager) GetOrCreate(ctx context.Context, envConf *config.En
147147
zap.String("key_id", storage.Signature.KeyID))
148148
}
149149
}
150+
if storage.RemoteStartupFetchPolicy != "" {
151+
opts = append(opts, storagegit.WithRemoteStartupFetchPolicy(storage.RemoteStartupFetchPolicy))
152+
}
150153

151154
newRepo, err := storagegit.NewRepository(ctx, logger, opts...)
152155
if err != nil {
@@ -512,7 +515,14 @@ func NewStore(ctx context.Context, logger *zap.Logger, cfg *config.Config, secre
512515
// branched environments have been added
513516
for _, repo := range repoManager.repos {
514517
if err := repo.Fetch(ctx); err != nil {
515-
if !errors.Is(err, transport.ErrEmptyRemoteRepository) && !errors.Is(err, git.ErrRemoteRefNotFound) {
518+
switch {
519+
case errors.Is(err, transport.ErrEmptyRemoteRepository):
520+
continue
521+
case errors.Is(err, git.ErrRemoteRefNotFound):
522+
continue
523+
case repo.IsConnectionRefused(err) && repo.RemoteStartupFetchPolicy() == "optional":
524+
continue
525+
default:
516526
return nil, err
517527
}
518528
}

internal/storage/git/repository.go

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"slices"
1010
"strings"
1111
"sync"
12+
"syscall"
1213
"time"
1314

1415
"github.com/go-git/go-billy/v6/osfs"
@@ -33,19 +34,20 @@ type Repository struct {
3334

3435
logger *zap.Logger
3536

36-
mu sync.RWMutex
37-
remote *config.RemoteConfig
38-
defaultBranch string
39-
auth transport.AuthMethod
40-
insecureSkipTLS bool
41-
caBundle []byte
42-
localPath string
43-
readme []byte
44-
sigName string
45-
sigEmail string
46-
signer signing.Signer
47-
maxOpenDescriptors int
48-
isNormalRepo bool // true if opened with PlainOpen, false if bare repository
37+
mu sync.RWMutex
38+
remote *config.RemoteConfig
39+
remoteStartupFetchPolicy string
40+
defaultBranch string
41+
auth transport.AuthMethod
42+
insecureSkipTLS bool
43+
caBundle []byte
44+
localPath string
45+
readme []byte
46+
sigName string
47+
sigEmail string
48+
signer signing.Signer
49+
maxOpenDescriptors int
50+
isNormalRepo bool // true if opened with PlainOpen, false if bare repository
4951

5052
subs []Subscriber
5153

@@ -71,7 +73,7 @@ func NewRepository(ctx context.Context, logger *zap.Logger, opts ...containers.O
7173
logger.Debug("repository empty, attempting to add and push a README")
7274
// add initial readme if repo is empty
7375
if _, err := repo.UpdateAndPush(ctx, repo.defaultBranch, func(fs envsfs.Filesystem) (string, error) {
74-
fi, err := fs.OpenFile("README.md", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
76+
fi, err := fs.OpenFile("README.md", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
7577
if err != nil {
7678
return "", err
7779
}
@@ -198,15 +200,28 @@ func newRepository(ctx context.Context, logger *zap.Logger, opts ...containers.O
198200

199201
// do an initial fetch to setup remote tracking branches
200202
if err := r.Fetch(ctx); err != nil {
201-
if !errors.Is(err, transport.ErrEmptyRemoteRepository) && !errors.Is(err, git.ErrRemoteRefNotFound) {
202-
return nil, empty, fmt.Errorf("performing initial fetch: %w", err)
203+
fetchErr := fmt.Errorf("performing initial fetch: %w", err)
204+
switch {
205+
case r.remoteStartupFetchPolicy == "optional" && r.IsConnectionRefused(err):
206+
// if optional, we check if the error is connection refused
207+
// and there is non-empty repo and flags could be evaluated
208+
objs, rerr := r.CommitObjects()
209+
if rerr != nil {
210+
return nil, empty, fetchErr
211+
}
212+
_, rerr = objs.Next()
213+
objs.Close()
214+
if rerr != nil {
215+
return nil, empty, fetchErr
216+
}
217+
case errors.Is(err, transport.ErrEmptyRemoteRepository) || errors.Is(err, git.ErrRemoteRefNotFound):
218+
// the remote was reachable but either its contents was completely empty
219+
// or our default branch doesn't exist and so we decide to seed it
220+
empty = true
221+
logger.Debug("initial fetch empty", zap.String("reference", r.defaultBranch), zap.Error(err))
222+
default:
223+
return nil, empty, fetchErr
203224
}
204-
205-
// the remote was reachable but either its contents was completely empty
206-
// or our default branch doesn't exist and so we decide to seed it
207-
empty = true
208-
209-
logger.Debug("initial fetch empty", zap.String("reference", r.defaultBranch), zap.Error(err))
210225
}
211226
}
212227

@@ -296,6 +311,17 @@ func (r *Repository) fetchHeads() []string {
296311
return slices.Collect(maps.Keys(heads))
297312
}
298313

314+
// IsConnectionRefused checks if the provided error is a connection refused error.
315+
func (r *Repository) IsConnectionRefused(err error) bool {
316+
return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.EHOSTUNREACH) ||
317+
errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTDOWN)
318+
}
319+
320+
// RemoteStartupFetchPolicy returns the fetch policy used when starting up with a remote configured.
321+
func (r *Repository) RemoteStartupFetchPolicy() string {
322+
return r.remoteStartupFetchPolicy
323+
}
324+
299325
// Fetch does a fetch for the requested head names on a configured remote.
300326
// If the remote is not defined, then it is a silent noop.
301327
// Iff specific is explicitly requested then only the heads in specific are fetched.
@@ -830,6 +856,13 @@ func WithMaxOpenDescriptors(n int) containers.Option[Repository] {
830856
}
831857
}
832858

859+
// withRemoteStartupFetchPolicy sets the fetch policy to use when starting up with a remote configured.
860+
func WithRemoteStartupFetchPolicy(policy string) containers.Option[Repository] {
861+
return func(r *Repository) {
862+
r.remoteStartupFetchPolicy = policy
863+
}
864+
}
865+
833866
const defaultReadmeContents = `Flipt Configuration Repository
834867
==============================
835868

0 commit comments

Comments
 (0)