Skip to content

Commit a9f61c5

Browse files
authored
feat: Add options to sync environment at dev-server startup (#463)
* Add options for start and sync * Add initial project sync * Fix imports * Add sync * Add context * Update sync * Add sync task * Refactor settings * Add overrides * Add test cases * Move to model * PR feedback * Fix doc
1 parent c66f8b9 commit a9f61c5

File tree

7 files changed

+307
-11
lines changed

7 files changed

+307
-11
lines changed

cmd/dev_server/flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ package dev_server
22

33
const (
44
ContextFlag = "context"
5+
OverrideFlag = "override"
56
SourceEnvironmentFlag = "source"
67
)

cmd/dev_server/start_server.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev_server
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"log"
78
"os/exec"
@@ -10,10 +11,12 @@ import (
1011
"github.com/spf13/cobra"
1112
"github.com/spf13/viper"
1213

14+
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
1315
"github.com/launchdarkly/ldcli/cmd/cliflags"
1416
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
1517
"github.com/launchdarkly/ldcli/cmd/validators"
1618
"github.com/launchdarkly/ldcli/internal/dev_server"
19+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
1720
)
1821

1922
func NewStartServerCmd(client dev_server.Client) *cobra.Command {
@@ -28,17 +31,61 @@ func NewStartServerCmd(client dev_server.Client) *cobra.Command {
2831

2932
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
3033

34+
cmd.Flags().String(cliflags.ProjectFlag, "", "The project key")
35+
_ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag))
36+
37+
cmd.Flags().String(SourceEnvironmentFlag, "", "environment to copy flag values from")
38+
_ = viper.BindPFlag(SourceEnvironmentFlag, cmd.Flags().Lookup(SourceEnvironmentFlag))
39+
40+
cmd.Flags().String(ContextFlag, "", `Stringified JSON representation of your context object ex. {"kind": "multi", "user": { "email": "[email protected]", "username": "foo", "key": "bar"}`)
41+
_ = viper.BindPFlag(ContextFlag, cmd.Flags().Lookup(ContextFlag))
42+
43+
cmd.Flags().String(OverrideFlag, "", `Stringified JSON representation of flag overrides ex. {"flagName": true, "stringFlagName": "test" }`)
44+
_ = viper.BindPFlag(OverrideFlag, cmd.Flags().Lookup(OverrideFlag))
45+
3146
return cmd
3247
}
3348

3449
func startServer(client dev_server.Client) func(*cobra.Command, []string) error {
3550
return func(cmd *cobra.Command, args []string) error {
3651
ctx := context.Background()
52+
53+
var initialSetting model.InitialProjectSettings
54+
55+
if viper.IsSet(cliflags.ProjectFlag) && viper.IsSet(SourceEnvironmentFlag) {
56+
57+
initialSetting = model.InitialProjectSettings{
58+
Enabled: true,
59+
ProjectKey: viper.GetString(cliflags.ProjectFlag),
60+
EnvKey: viper.GetString(SourceEnvironmentFlag),
61+
}
62+
if viper.IsSet(ContextFlag) {
63+
var c ldcontext.Context
64+
contextString := viper.GetString(ContextFlag)
65+
err := c.UnmarshalJSON([]byte(contextString))
66+
if err != nil {
67+
return err
68+
}
69+
initialSetting.Context = &c
70+
}
71+
72+
if viper.IsSet(OverrideFlag) {
73+
var override map[string]model.FlagValue
74+
overrideString := viper.GetString(OverrideFlag)
75+
err := json.Unmarshal([]byte(overrideString), &override)
76+
if err != nil {
77+
return err
78+
}
79+
initialSetting.Overrides = override
80+
}
81+
}
82+
3783
params := dev_server.ServerParams{
38-
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
39-
BaseURI: viper.GetString(cliflags.BaseURIFlag),
40-
DevStreamURI: viper.GetString(cliflags.DevStreamURIFlag),
41-
Port: viper.GetString(cliflags.PortFlag),
84+
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
85+
BaseURI: viper.GetString(cliflags.BaseURIFlag),
86+
DevStreamURI: viper.GetString(cliflags.DevStreamURIFlag),
87+
Port: viper.GetString(cliflags.PortFlag),
88+
InitialProjectSettings: initialSetting,
4289
}
4390

4491
client.RunServer(ctx, params)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package adapters
2+
3+
import (
4+
"context"
5+
ldapi "github.com/launchdarkly/api-client-go/v14"
6+
)
7+
8+
func WithApiAndSdk(ctx context.Context, client ldapi.APIClient, streamingUrl string) context.Context {
9+
ctx = WithSdk(ctx, newSdk(streamingUrl))
10+
ctx = WithApi(ctx, NewApi(client))
11+
return ctx
12+
}

internal/dev_server/adapters/middleware.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ func Middleware(client ldapi.APIClient, streamingUrl string) func(handler http.H
1313
return func(handler http.Handler) http.Handler {
1414
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
1515
ctx := request.Context()
16-
ctx = WithSdk(ctx, newSdk(streamingUrl))
17-
ctx = WithApi(ctx, NewApi(client))
16+
ctx = WithApiAndSdk(ctx, client, streamingUrl)
1817
request = request.WithContext(ctx)
1918
handler.ServeHTTP(writer, request)
2019
})

internal/dev_server/dev_server.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ type Client interface {
2525
}
2626

2727
type ServerParams struct {
28-
AccessToken string
29-
BaseURI string
30-
DevStreamURI string
31-
Port string
28+
AccessToken string
29+
BaseURI string
30+
DevStreamURI string
31+
Port string
32+
InitialProjectSettings model.InitialProjectSettings
3233
}
3334

3435
type LDClient struct {
@@ -49,6 +50,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
4950
if err != nil {
5051
log.Fatal(err)
5152
}
53+
observers := model.NewObservers()
5254
ss := api.NewStrictServer()
5355
apiServer := api.NewStrictHandlerWithOptions(ss, nil, api.StrictHTTPServerOptions{
5456
RequestErrorHandlerFunc: api.RequestErrorHandler,
@@ -57,7 +59,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
5759
r := mux.NewRouter()
5860
r.Use(adapters.Middleware(*ldClient, serverParams.DevStreamURI))
5961
r.Use(model.StoreMiddleware(sqlStore))
60-
r.Use(model.ObserversMiddleware(model.NewObservers()))
62+
r.Use(model.ObserversMiddleware(observers))
6163
r.Handle("/", http.RedirectHandler("/ui/", http.StatusFound))
6264
r.Handle("/ui", http.RedirectHandler("/ui/", http.StatusMovedPermanently))
6365
r.PathPrefix("/ui/").Handler(http.StripPrefix("/ui/", ui.AssetHandler))
@@ -66,9 +68,18 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
6668
handler = handlers.CombinedLoggingHandler(os.Stdout, handler)
6769
handler = handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))(handler)
6870

71+
ctx = adapters.WithApiAndSdk(ctx, *ldClient, serverParams.DevStreamURI)
72+
ctx = model.SetObserversOnContext(ctx, observers)
73+
ctx = model.ContextWithStore(ctx, sqlStore)
74+
syncErr := model.CreateOrSyncProject(ctx, serverParams.InitialProjectSettings)
75+
if syncErr != nil {
76+
log.Fatal(syncErr)
77+
}
78+
6979
addr := fmt.Sprintf("0.0.0.0:%s", serverParams.Port)
7080
log.Printf("Server running on %s", addr)
7181
log.Printf("Access the UI for toggling overrides at http://localhost:%s/ui or by running `ldcli dev-server ui`", serverParams.Port)
82+
7283
server := http.Server{
7384
Addr: addr,
7485
Handler: handler,

internal/dev_server/model/sync.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"log"
6+
7+
"github.com/pkg/errors"
8+
9+
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
10+
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
11+
)
12+
13+
type FlagValue = ldvalue.Value
14+
15+
type InitialProjectSettings struct {
16+
Enabled bool
17+
ProjectKey string
18+
EnvKey string
19+
Context *ldcontext.Context `json:"context,omitempty"`
20+
Overrides map[string]FlagValue `json:"overrides,omitempty"`
21+
}
22+
23+
func CreateOrSyncProject(ctx context.Context, settings InitialProjectSettings) error {
24+
if !settings.Enabled {
25+
return nil
26+
}
27+
28+
log.Printf("Initial project [%s] with env [%s]", settings.ProjectKey, settings.EnvKey)
29+
var project Project
30+
project, createError := CreateProject(ctx, settings.ProjectKey, settings.EnvKey, settings.Context)
31+
if createError != nil {
32+
if errors.Is(createError, ErrAlreadyExists) {
33+
log.Printf("Project [%s] exists, refreshing data", settings.ProjectKey)
34+
var updateErr error
35+
project, updateErr = UpdateProject(ctx, settings.ProjectKey, settings.Context, &settings.EnvKey)
36+
if updateErr != nil {
37+
return updateErr
38+
}
39+
40+
} else {
41+
return createError
42+
}
43+
}
44+
for flagKey, val := range settings.Overrides {
45+
_, err := UpsertOverride(ctx, settings.ProjectKey, flagKey, val)
46+
if err != nil {
47+
return err
48+
}
49+
}
50+
51+
log.Printf("Successfully synced Initial project [%s]", project.Key)
52+
return nil
53+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package model_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/pkg/errors"
8+
"github.com/stretchr/testify/assert"
9+
"go.uber.org/mock/gomock"
10+
11+
ldapi "github.com/launchdarkly/api-client-go/v14"
12+
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
13+
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
14+
"github.com/launchdarkly/go-server-sdk/v7/interfaces/flagstate"
15+
adapters_mocks "github.com/launchdarkly/ldcli/internal/dev_server/adapters/mocks"
16+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
17+
"github.com/launchdarkly/ldcli/internal/dev_server/model/mocks"
18+
)
19+
20+
func TestInitialSync(t *testing.T) {
21+
22+
ctx := context.Background()
23+
mockController := gomock.NewController(t)
24+
observers := model.NewObservers()
25+
ctx, api, sdk := adapters_mocks.WithMockApiAndSdk(ctx, mockController)
26+
store := mocks.NewMockStore(mockController)
27+
ctx = model.ContextWithStore(ctx, store)
28+
ctx = model.SetObserversOnContext(ctx, observers)
29+
projKey := "proj"
30+
sourceEnvKey := "env"
31+
sdkKey := "thing"
32+
33+
allFlagsState := flagstate.NewAllFlagsBuilder().
34+
AddFlag("boolFlag", flagstate.FlagState{Value: ldvalue.Bool(true)}).
35+
Build()
36+
37+
trueVariationId, falseVariationId := "true", "false"
38+
allFlags := []ldapi.FeatureFlag{{
39+
Name: "bool flag",
40+
Kind: "bool",
41+
Key: "boolFlag",
42+
Variations: []ldapi.Variation{
43+
{
44+
Id: &trueVariationId,
45+
Value: true,
46+
},
47+
{
48+
Id: &falseVariationId,
49+
Value: false,
50+
},
51+
},
52+
}}
53+
54+
t.Run("Returns no error if disabled", func(t *testing.T) {
55+
input := model.InitialProjectSettings{
56+
Enabled: false,
57+
ProjectKey: projKey,
58+
EnvKey: sourceEnvKey,
59+
Context: nil,
60+
Overrides: nil,
61+
}
62+
err := model.CreateOrSyncProject(ctx, input)
63+
assert.NoError(t, err)
64+
})
65+
66+
t.Run("Returns error if it cant fetch flag state", func(t *testing.T) {
67+
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return("", errors.New("fetch flag state fails"))
68+
input := model.InitialProjectSettings{
69+
Enabled: true,
70+
ProjectKey: projKey,
71+
EnvKey: sourceEnvKey,
72+
Context: nil,
73+
Overrides: nil,
74+
}
75+
err := model.CreateOrSyncProject(ctx, input)
76+
assert.NotNil(t, err)
77+
assert.Equal(t, "fetch flag state fails", err.Error())
78+
})
79+
80+
t.Run("Returns error if it can't fetch flags", func(t *testing.T) {
81+
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
82+
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
83+
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(nil, errors.New("fetch flags failed"))
84+
input := model.InitialProjectSettings{
85+
Enabled: true,
86+
ProjectKey: projKey,
87+
EnvKey: sourceEnvKey,
88+
Context: nil,
89+
Overrides: nil,
90+
}
91+
err := model.CreateOrSyncProject(ctx, input)
92+
assert.NotNil(t, err)
93+
assert.Equal(t, "fetch flags failed", err.Error())
94+
})
95+
96+
t.Run("Returns error if it fails to insert the project", func(t *testing.T) {
97+
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
98+
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
99+
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
100+
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(errors.New("insert fails"))
101+
102+
input := model.InitialProjectSettings{
103+
Enabled: true,
104+
ProjectKey: projKey,
105+
EnvKey: sourceEnvKey,
106+
Context: nil,
107+
Overrides: nil,
108+
}
109+
err := model.CreateOrSyncProject(ctx, input)
110+
assert.NotNil(t, err)
111+
assert.Equal(t, "insert fails", err.Error())
112+
})
113+
114+
t.Run("Successfully creates project", func(t *testing.T) {
115+
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
116+
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
117+
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
118+
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil)
119+
120+
input := model.InitialProjectSettings{
121+
Enabled: true,
122+
ProjectKey: projKey,
123+
EnvKey: sourceEnvKey,
124+
Context: nil,
125+
Overrides: nil,
126+
}
127+
err := model.CreateOrSyncProject(ctx, input)
128+
129+
assert.NoError(t, err)
130+
})
131+
t.Run("Successfully creates project with override", func(t *testing.T) {
132+
override := model.Override{
133+
ProjectKey: projKey,
134+
FlagKey: "boolFlag",
135+
Value: ldvalue.Bool(true),
136+
Active: true,
137+
Version: 1,
138+
}
139+
140+
proj := model.Project{
141+
Key: projKey,
142+
SourceEnvironmentKey: sourceEnvKey,
143+
Context: ldcontext.New(t.Name()),
144+
AllFlagsState: map[string]model.FlagState{
145+
"boolFlag": {
146+
Version: 0,
147+
Value: ldvalue.Bool(false),
148+
},
149+
},
150+
}
151+
152+
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
153+
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
154+
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
155+
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil)
156+
store.EXPECT().UpsertOverride(gomock.Any(), override).Return(override, nil)
157+
store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(&proj, nil)
158+
159+
input := model.InitialProjectSettings{
160+
Enabled: true,
161+
ProjectKey: projKey,
162+
EnvKey: sourceEnvKey,
163+
Context: nil,
164+
Overrides: map[string]model.FlagValue{
165+
"boolFlag": ldvalue.Bool(true),
166+
},
167+
}
168+
err := model.CreateOrSyncProject(ctx, input)
169+
170+
assert.NoError(t, err)
171+
})
172+
173+
}

0 commit comments

Comments
 (0)