feat(BRIDGE-119): added support for Feature Flags

This commit is contained in:
Atanas Janeshliev
2024-08-07 17:04:54 +02:00
parent 3d53bf7477
commit e290cd308b
11 changed files with 377 additions and 1 deletions

2
go.mod
View File

@ -9,7 +9,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20240612082117-0f92424eed80
github.com/ProtonMail/go-proton-api v0.4.1-0.20240821081056-dd607af0f917
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible

6
go.sum
View File

@ -40,6 +40,12 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240612082117-0f92424eed80 h1:cP4+6RFn9vVgYnoDwxBU4EtIAZA+eM4rzOaSZNqZ1xg=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240612082117-0f92424eed80/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240808145610-88df257767f6 h1:nERxOYS4ndSgWEr834YYkb1j0bZK/dJAmhoyYB1MtNY=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240808145610-88df257767f6/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240819131705-149e50199c5b h1:zifGh4LS5HwQIaVCccSe5/oJGTOjFeVObMRl3QJoJ3k=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240819131705-149e50199c5b/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240821081056-dd607af0f917 h1:Ma6PfXFDuw7rYYq28FXNW6ubhYquRUmBuLyZrjJWHUE=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240821081056-dd607af0f917/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

@ -47,6 +47,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
@ -136,6 +137,8 @@ type Bridge struct {
serverManager *imapsmtpserver.Service
syncService *syncservice.Service
// unleashService is responsible for polling the feature flags and caching
unleashService *unleash.Service
}
var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals
@ -253,6 +256,8 @@ func newBridge(
return nil, fmt.Errorf("failed to create focus service: %w", err)
}
unleashService := unleash.NewBridgeService(ctx, api, locator, panicHandler)
bridge := &Bridge{
vault: vault,
@ -293,6 +298,8 @@ func newBridge(
tasks: tasks,
syncService: syncservice.NewService(reporter, panicHandler),
unleashService: unleashService,
}
bridge.serverManager = imapsmtpserver.NewService(context.Background(),
@ -320,6 +327,8 @@ func newBridge(
bridge.syncService.Run()
bridge.unleashService.Run()
return bridge, nil
}
@ -470,6 +479,9 @@ func (bridge *Bridge) Close(ctx context.Context) {
// Close the focus service.
bridge.focusService.Close()
// Close the unleash service.
bridge.unleashService.Close()
// Close the watchers.
bridge.watchersLock.Lock()
defer bridge.watchersLock.Unlock()
@ -674,3 +686,7 @@ func GetUpdatedCachePath(gluonDBPath, gluonCachePath string) string {
return strings.Replace(gluonCachePath, "/Users/"+cacheUsername+"/", "/Users/"+dbUsername+"/", 1)
}
func (bridge *Bridge) GetFeatureFlagValue(key string) bool {
return bridge.unleashService.GetFlagValue(key)
}

View File

@ -33,6 +33,7 @@ type Locator interface {
GetDependencyLicensesLink() string
Clear(...string) error
ProvideIMAPSyncConfigPath() (string, error)
ProvideUnleashCachePath() (string, error)
}
type ProxyController interface {

View File

@ -0,0 +1,90 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge_test
import (
"context"
"testing"
"time"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/stretchr/testify/require"
)
func Test_UnleashService(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
// Initial startup assumes there is no cached feature flags.
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-1")
s.PushFeatureFlag("test-2")
// Wait for poll.
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-3")
time.Sleep(time.Millisecond * 700) // Wait for poll again
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
})
// Wait for Bridge to close.
time.Sleep(time.Millisecond * 500)
// Second instance should have a feature flag cache file available. Therefore, all of the flags should evaluate to true on startup.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
s.DeleteFeatureFlags()
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-3")
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
})
})
}

View File

@ -206,6 +206,16 @@ func (l *Locations) ProvideIMAPSyncConfigPath() (string, error) {
return l.getIMAPSyncConfigPath(), nil
}
// ProvideUnleashCachePath returns a location for the unleash cache data (e.g. ~/.cache/protonmail/bridge-v3).
// It creates it if it doesn't already exist.
func (l *Locations) ProvideUnleashCachePath() (string, error) {
if err := os.MkdirAll(l.getUnleashCachePath(), 0o700); err != nil {
return "", err
}
return l.getUnleashCachePath(), nil
}
func (l *Locations) getGluonCachePath() string {
return filepath.Join(l.userData, "gluon")
}
@ -242,6 +252,8 @@ func (l *Locations) getStatsPath() string {
return filepath.Join(l.userData, "stats")
}
func (l *Locations) getUnleashCachePath() string { return filepath.Join(l.userCache, "unleash_cache") }
// Clear removes everything except the lock and update files.
func (l *Locations) Clear(except ...string) error {
return files.Remove(

View File

@ -19,4 +19,5 @@ package service
type Locator interface {
ProvideSettingsPath() (string, error)
ProvideUnleashCachePath() (string, error)
}

234
internal/unleash/service.go Normal file
View File

@ -0,0 +1,234 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package unleash
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/sirupsen/logrus"
)
var pollPeriod = 10 * time.Minute //nolint:gochecknoglobals
var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags"
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
type Service struct {
panicHandler async.PanicHandler
timer *proton.Ticker
ctx context.Context
cancel context.CancelFunc
log *logrus.Entry
ffStore map[string]bool
ffStoreLock sync.Mutex
cacheFilepath string
cacheFileLock sync.Mutex
channel chan map[string]bool
getFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
}
func NewBridgeService(ctx context.Context, api *proton.Manager, locator service.Locator, panicHandler async.PanicHandler) *Service {
log := logrus.WithField("service", "unleash")
cacheDir, err := locator.ProvideUnleashCachePath()
if err != nil {
log.Warn("Could not find or create unleash cache directory")
}
cachePath := filepath.Clean(filepath.Join(cacheDir, filename))
return newService(ctx, func(ctx context.Context) (proton.FeatureFlagResult, error) {
return api.GetFeatures(ctx)
}, log, cachePath, panicHandler)
}
func newService(ctx context.Context, fn requestFeaturesFn, log *logrus.Entry, cachePath string, panicHandler async.PanicHandler) *Service {
ctx, cancel := context.WithCancel(ctx)
unleashService := &Service{
panicHandler: panicHandler,
timer: proton.NewTicker(pollPeriod, pollJitter, panicHandler),
ctx: ctx,
cancel: cancel,
log: log,
ffStore: make(map[string]bool),
cacheFilepath: cachePath,
channel: make(chan map[string]bool),
getFeaturesFn: fn,
}
unleashService.readCacheFile()
return unleashService
}
func readResponseData(data proton.FeatureFlagResult) map[string]bool {
featureData := make(map[string]bool)
for _, el := range data.Toggles {
featureData[el.Name] = el.Enabled
}
return featureData
}
func (s *Service) readCacheFile() {
defer s.cacheFileLock.Unlock()
s.cacheFileLock.Lock()
file, err := os.Open(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Error("Unable to open cache file")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after read")
}
}(file)
s.ffStoreLock.Lock()
defer s.ffStoreLock.Unlock()
if err = json.NewDecoder(file).Decode(&s.ffStore); err != nil {
s.log.WithError(err).Error("Unable to decode cache file")
}
}
func (s *Service) writeCacheFile() {
defer s.cacheFileLock.Unlock()
s.cacheFileLock.Lock()
file, err := os.Create(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Error("Unable to create cache file")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after write")
}
}(file)
s.ffStoreLock.Lock()
defer s.ffStoreLock.Unlock()
if err = json.NewEncoder(file).Encode(s.ffStore); err != nil {
s.log.WithError(err).Error("Unable to encode data to cache file")
}
}
func (s *Service) Run() {
s.log.Info("Starting service")
go func() {
s.runFlagPoll()
}()
go func() {
s.runReceiver()
}()
}
func (s *Service) runFlagPoll() {
defer async.HandlePanic(s.panicHandler)
defer s.timer.Stop()
s.log.Info("Starting poll service")
data, err := s.getFeaturesFn(s.ctx)
if err != nil {
s.log.WithError(err).Error("Failed to get flags from server")
} else {
s.channel <- readResponseData(data)
}
for {
select {
case <-s.ctx.Done():
return
case <-s.timer.C:
s.log.Info("Polling flag service")
data, err := s.getFeaturesFn(s.ctx)
if err != nil {
s.log.WithError(err).Error("Failed to get feature flags from server")
continue
}
s.channel <- readResponseData(data)
}
}
}
func (s *Service) runReceiver() {
defer async.HandlePanic(s.panicHandler)
s.log.Info("Starting receiver service")
for {
select {
case <-s.ctx.Done():
return
case res := <-s.channel:
s.ffStoreLock.Lock()
s.ffStore = res
s.ffStoreLock.Unlock()
s.writeCacheFile()
}
}
}
func (s *Service) GetFlagValue(key string) bool {
defer s.ffStoreLock.Unlock()
s.ffStoreLock.Lock()
val, ok := s.ffStore[key]
if !ok {
return false
}
return val
}
func (s *Service) Close() {
s.log.Info("Closing service")
s.cancel()
close(s.channel)
}
// ModifyPollPeriodAndJitter is only used for testing.
func ModifyPollPeriodAndJitter(pollInterval, jitterInterval time.Duration) {
pollPeriod = pollInterval
pollJitter = jitterInterval
}

View File

@ -149,6 +149,11 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
}
rt := t.netCtl.NewRoundTripper(&tls.Config{InsecureSkipVerify: true})
// We store the round tripper in the testing context so we can cancel the connection
// when we're turning it down/up
t.rt = &rt
if isBlack() {
// GODT-1602 make sure we don't time out test server
t, ok := rt.(*http.Transport)

View File

@ -21,6 +21,7 @@ import (
"context"
"fmt"
"net"
"net/http"
"net/smtp"
"net/url"
"regexp"
@ -164,6 +165,8 @@ type testCtx struct {
imapServerStarted bool
smtpServerStarted bool
rt *http.RoundTripper
}
type imapClient struct {

View File

@ -59,11 +59,19 @@ func (s *scenario) itFailsWithError(wantErr string) error {
func (s *scenario) internetIsTurnedOff() error {
s.t.netCtl.SetCanDial(false)
t, ok := (*s.t.rt).(*http.Transport)
if ok {
t.CloseIdleConnections()
}
return nil
}
func (s *scenario) internetIsTurnedOn() error {
s.t.netCtl.SetCanDial(true)
t, ok := (*s.t.rt).(*http.Transport)
if ok {
t.CloseIdleConnections()
}
return nil
}