diff --git a/backend/controller/controller.go b/backend/controller/controller.go index b267b6dbfb..9aaf670b4c 100644 --- a/backend/controller/controller.go +++ b/backend/controller/controller.go @@ -641,11 +641,11 @@ func (s *Service) GetModuleContext(ctx context.Context, req *connect.Request[ftl if !ok { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("module %q not found", req.Msg.Module)) } - moduleContext, err := modulecontext.New(module.Name).UpdateFromEnvironment(ctx) + builder, err := modulecontext.NewBuilder(module.Name).UpdateFromEnvironment(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("could not get module context: %w", err)) } - response, err := moduleContext.ToProto() + response, err := builder.Build().ToProto() if err != nil { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("could not marshal module context: %w", err)) } diff --git a/go-runtime/ftl/config_test.go b/go-runtime/ftl/config_test.go index 31fd5dedf5..6e8550f694 100644 --- a/go-runtime/ftl/config_test.go +++ b/go-runtime/ftl/config_test.go @@ -22,13 +22,7 @@ func TestConfig(t *testing.T) { data, err := json.Marshal(C{"one", "two"}) assert.NoError(t, err) - moduleCtx := modulecontext.New("test").Update( - map[string][]byte{ - "test": data, - }, - map[string][]byte{}, - map[string]modulecontext.Database{}, - ) + moduleCtx := modulecontext.NewBuilder("test").AddConfigs(map[string][]byte{"test": data}).Build() ctx = moduleCtx.ApplyToContext(ctx) config := Config[C]("test") diff --git a/go-runtime/ftl/ftltest/ftltest.go b/go-runtime/ftl/ftltest/ftltest.go index 91fd500ff3..7d0ea6cfc8 100644 --- a/go-runtime/ftl/ftltest/ftltest.go +++ b/go-runtime/ftl/ftltest/ftltest.go @@ -36,14 +36,13 @@ func Context(options ...func(*Options) error) context.Context { } } - moduleCtx := modulecontext.New(ftl.Module()) - moduleCtx, err := moduleCtx.UpdateFromEnvironment(ctx) + builder, err := modulecontext.NewBuilder(ftl.Module()).UpdateFromEnvironment(ctx) if err != nil { panic(fmt.Sprintf("error setting up module context from environment: %v", err)) } - moduleCtx = moduleCtx.Update(state.configs, state.secrets, map[string]modulecontext.Database{}) - moduleCtx = moduleCtx.UpdateForTesting(state.mockVerbs, state.allowDirectVerbBehavior) - return moduleCtx.ApplyToContext(ctx) + builder = builder.AddConfigs(state.configs).AddSecrets(state.secrets).AddDatabases(map[string]modulecontext.Database{}) + builder = builder.UpdateForTesting(state.mockVerbs, state.allowDirectVerbBehavior) + return builder.Build().ApplyToContext(ctx) } // WithConfig sets a configuration for the current module diff --git a/go-runtime/ftl/secrets_test.go b/go-runtime/ftl/secrets_test.go index ccad75ebd7..7cea735935 100644 --- a/go-runtime/ftl/secrets_test.go +++ b/go-runtime/ftl/secrets_test.go @@ -22,13 +22,7 @@ func TestSecret(t *testing.T) { data, err := json.Marshal(C{"one", "two"}) assert.NoError(t, err) - moduleCtx := modulecontext.New("test").Update( - map[string][]byte{}, - map[string][]byte{ - "test": data, - }, - map[string]modulecontext.Database{}, - ) + moduleCtx := modulecontext.NewBuilder("test").AddSecrets(map[string][]byte{"test": data}).Build() ctx = moduleCtx.ApplyToContext(ctx) secret := Secret[C]("test") diff --git a/go-runtime/modulecontext/from_environment.go b/go-runtime/modulecontext/from_environment.go index dc11f5c68f..7f972db1ef 100644 --- a/go-runtime/modulecontext/from_environment.go +++ b/go-runtime/modulecontext/from_environment.go @@ -9,35 +9,35 @@ import ( cf "github.com/TBD54566975/ftl/common/configuration" ) -// UpdateFromEnvironment copies a ModuleContext and gathers configs, secrets and databases from the local environment. +// UpdateFromEnvironment adds configs, secrets and databases from the environment. // // This is useful for testing and development, where the environment is used to provide // configurations, secrets and DSNs. The context is built from a combination of // the ftl-project.toml file and (for now) environment variables. -func (m ModuleContext) UpdateFromEnvironment(ctx context.Context) (ModuleContext, error) { +func (b *Builder) UpdateFromEnvironment(ctx context.Context) (*Builder, error) { // TODO: split this func into separate purposes: explicitly reading a particular project file, and reading DSNs from environment cm, err := cf.NewDefaultConfigurationManagerFromEnvironment(ctx) if err != nil { - return ModuleContext{}, err + return &Builder{}, err } - configs, err := cm.MapForModule(ctx, m.module) + configs, err := cm.MapForModule(ctx, b.module) if err != nil { - return ModuleContext{}, err + return &Builder{}, err } for name, data := range configs { - m.configs[name] = data + b.configs[name] = data } sm, err := cf.NewDefaultSecretsManagerFromEnvironment(ctx) if err != nil { - return ModuleContext{}, err + return &Builder{}, err } - secrets, err := sm.MapForModule(ctx, m.module) + secrets, err := sm.MapForModule(ctx, b.module) if err != nil { - return ModuleContext{}, err + return &Builder{}, err } for name, data := range secrets { - m.secrets[name] = data + b.secrets[name] = data } for _, entry := range os.Environ() { @@ -46,26 +46,26 @@ func (m ModuleContext) UpdateFromEnvironment(ctx context.Context) (ModuleContext } parts := strings.SplitN(entry, "=", 2) if len(parts) != 2 { - return ModuleContext{}, fmt.Errorf("invalid DSN environment variable: %s", entry) + return &Builder{}, fmt.Errorf("invalid DSN environment variable: %s", entry) } key := parts[0] value := parts[1] // FTL_POSTGRES_DSN_MODULE_DBNAME parts = strings.Split(key, "_") if len(parts) != 5 { - return ModuleContext{}, fmt.Errorf("invalid DSN environment variable: %s", entry) + return &Builder{}, fmt.Errorf("invalid DSN environment variable: %s", entry) } moduleName := parts[3] dbName := parts[4] - if !strings.EqualFold(moduleName, m.module) { + if !strings.EqualFold(moduleName, b.module) { continue } dbName = strings.ToLower(dbName) db, err := NewDatabase(DBTypePostgres, value) if err != nil { - return ModuleContext{}, fmt.Errorf("could not create database %q with DSN %q: %w", dbName, value, err) + return &Builder{}, fmt.Errorf("could not create database %q with DSN %q: %w", dbName, value, err) } - m.databases[dbName] = db + b.databases[dbName] = db } - return m, nil + return b, nil } diff --git a/go-runtime/modulecontext/from_environment_test.go b/go-runtime/modulecontext/from_environment_test.go index 6d28ebac0e..0bc6e7d260 100644 --- a/go-runtime/modulecontext/from_environment_test.go +++ b/go-runtime/modulecontext/from_environment_test.go @@ -37,10 +37,9 @@ func TestFromEnvironment(t *testing.T) { ctx := log.ContextWithNewDefaultLogger(context.Background()) - moduleContext, err := New("echo").UpdateFromEnvironment(ctx) + builder, err := NewBuilder("echo").UpdateFromEnvironment(ctx) assert.NoError(t, err) - - response, err := moduleContext.ToProto() + response, err := builder.Build().ToProto() assert.NoError(t, err) assert.Equal(t, &ftlv1.ModuleContextResponse{ diff --git a/go-runtime/modulecontext/from_proto.go b/go-runtime/modulecontext/from_proto.go index 155853b8e5..1ddefde987 100644 --- a/go-runtime/modulecontext/from_proto.go +++ b/go-runtime/modulecontext/from_proto.go @@ -15,5 +15,5 @@ func FromProto(response *ftlv1.ModuleContextResponse) (ModuleContext, error) { } databases[entry.Name] = db } - return New(response.Module).Update(response.Configs, response.Secrets, databases), nil + return NewBuilder(response.Module).AddConfigs(response.Configs).AddSecrets(response.Secrets).AddDatabases(databases).Build(), nil } diff --git a/go-runtime/modulecontext/module_context.go b/go-runtime/modulecontext/module_context.go index 69b43a3d96..19f925eb7d 100644 --- a/go-runtime/modulecontext/module_context.go +++ b/go-runtime/modulecontext/module_context.go @@ -12,6 +12,7 @@ import ( ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/internal/reflect" ) type MockVerb func(ctx context.Context, req any) (resp any, err error) @@ -52,6 +53,8 @@ func (x DBType) String() string { } // ModuleContext holds the context needed for a module, including configs, secrets and DSNs +// +// ModuleContext is immutable type ModuleContext struct { module string configs map[string][]byte @@ -63,11 +66,14 @@ type ModuleContext struct { allowDirectVerbBehavior bool } +// Builder is used to build a ModuleContext +type Builder ModuleContext + type contextKeyModuleContext struct{} -// New creates a new blank ModuleContext for the given module. -func New(module string) ModuleContext { - return ModuleContext{ +// NewBuilder creates a new blank Builder for the given module. +func NewBuilder(module string) *Builder { + return &Builder{ module: module, configs: map[string][]byte{}, secrets: map[string][]byte{}, @@ -76,28 +82,42 @@ func New(module string) ModuleContext { } } -// Update copies a ModuleContext and adds configs, secrets and databases. -func (m ModuleContext) Update(configs map[string][]byte, secrets map[string][]byte, databases map[string]Database) ModuleContext { +// AddConfigs adds configuration values (as bytes) to the builder +func (b *Builder) AddConfigs(configs map[string][]byte) *Builder { for name, data := range configs { - m.configs[name] = data + b.configs[name] = data } + return b +} + +// AddSecrets adds configuration values (as bytes) to the builder +func (b *Builder) AddSecrets(secrets map[string][]byte) *Builder { for name, data := range secrets { - m.secrets[name] = data + b.secrets[name] = data } + return b +} + +// AddDatabases adds databases to the builder +func (b *Builder) AddDatabases(databases map[string]Database) *Builder { for name, db := range databases { - m.databases[name] = db + b.databases[name] = db } - return m + return b } -// UpdateForTesting copies a ModuleContext and marks it as part of a test environment and adds mock verbs and flags for other test features. -func (m ModuleContext) UpdateForTesting(mockVerbs map[schema.RefKey]MockVerb, allowDirectVerbBehavior bool) ModuleContext { - m.isTesting = true +// UpdateForTesting marks the builder as part of a test environment and adds mock verbs and flags for other test features. +func (b *Builder) UpdateForTesting(mockVerbs map[schema.RefKey]MockVerb, allowDirectVerbBehavior bool) *Builder { + b.isTesting = true for name, verb := range mockVerbs { - m.mockVerbs[name] = verb + b.mockVerbs[name] = verb } - m.allowDirectVerbBehavior = allowDirectVerbBehavior - return m + b.allowDirectVerbBehavior = allowDirectVerbBehavior + return b +} + +func (b *Builder) Build() ModuleContext { + return ModuleContext(reflect.DeepCopy(*b)) } // FromContext returns the ModuleContext attached to a context. diff --git a/go-runtime/modulecontext/module_context_test.go b/go-runtime/modulecontext/module_context_test.go index 92357c92da..468ac3f1e0 100644 --- a/go-runtime/modulecontext/module_context_test.go +++ b/go-runtime/modulecontext/module_context_test.go @@ -11,7 +11,7 @@ import ( func TestGettingAndSettingFromContext(t *testing.T) { ctx := log.ContextWithNewDefaultLogger(context.Background()) - moduleCtx := New("test") + moduleCtx := NewBuilder("test").Build() ctx = moduleCtx.ApplyToContext(ctx) assert.Equal(t, moduleCtx, FromContext(ctx), "module context should be the same when read from context") }