diff --git a/go-runtime/ftl/ftltest/contextbuilder.go b/go-runtime/ftl/ftltest/contextbuilder.go new file mode 100644 index 0000000000..5158ea0479 --- /dev/null +++ b/go-runtime/ftl/ftltest/contextbuilder.go @@ -0,0 +1,51 @@ +package ftltest + +import ( + "context" + + "github.com/TBD54566975/ftl/go-runtime/modulecontext" +) + +type DBType int32 + +const ( + DBTypePostgres = DBType(modulecontext.DBTypePostgres) +) + +// ContextBuilder allows for building a context with configuration, secrets and DSN values set up for tests +type ContextBuilder struct { + builder *modulecontext.Builder +} + +func NewContextBuilder(moduleName string) *ContextBuilder { + return &ContextBuilder{ + builder: modulecontext.NewBuilder(moduleName), + } +} + +func (b *ContextBuilder) AddConfig(name string, value any) *ContextBuilder { + b.builder.AddConfig(name, value) + return b +} + +func (b *ContextBuilder) AddSecret(name string, value any) *ContextBuilder { + b.builder.AddSecret(name, value) + return b +} + +func (b *ContextBuilder) AddDSN(name string, dbType DBType, dsn string) *ContextBuilder { + b.builder.AddDSN(name, modulecontext.DBType(dbType), dsn) + return b +} + +func (b *ContextBuilder) Build() (context.Context, error) { + return b.BuildWithContext(Context()) +} + +func (b *ContextBuilder) BuildWithContext(ctx context.Context) (context.Context, error) { + moduleCtx, err := b.builder.Build(ctx) + if err != nil { + return nil, err + } + return moduleCtx.ApplyToContext(ctx), nil +} diff --git a/go-runtime/modulecontext/builder.go b/go-runtime/modulecontext/builder.go new file mode 100644 index 0000000000..745679e71a --- /dev/null +++ b/go-runtime/modulecontext/builder.go @@ -0,0 +1,133 @@ +package modulecontext + +import ( + "context" + "fmt" + + ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" + cf "github.com/TBD54566975/ftl/common/configuration" + "github.com/alecthomas/types/optional" +) + +type dsnEntry struct { + dbType DBType + dsn string +} + +type valueOrData struct { + value optional.Option[any] + data optional.Option[[]byte] +} + +// Builder is used to set up a ModuleContext with configs, secrets and DSNs +// It is able to parse a ModuleContextResponse +type Builder struct { + moduleName string + configs map[string]valueOrData + secrets map[string]valueOrData + dsns map[string]dsnEntry +} + +func NewBuilder(moduleName string) *Builder { + return &Builder{ + moduleName: moduleName, + configs: map[string]valueOrData{}, + secrets: map[string]valueOrData{}, + dsns: map[string]dsnEntry{}, + } +} + +func NewBuilderFromProto(moduleName string, response *ftlv1.ModuleContextResponse) *Builder { + configs := map[string]valueOrData{} + for name, bytes := range response.Configs { + configs[name] = valueOrData{data: optional.Some(bytes)} + } + secrets := map[string]valueOrData{} + for name, bytes := range response.Secrets { + secrets[name] = valueOrData{data: optional.Some(bytes)} + } + dsns := map[string]dsnEntry{} + for _, d := range response.Databases { + dsns[d.Name] = dsnEntry{dbType: DBType(d.Type), dsn: d.Dsn} + } + return &Builder{ + moduleName: moduleName, + configs: configs, + secrets: secrets, + dsns: dsns, + } +} + +func (b *Builder) AddConfig(name string, value any) *Builder { + b.configs[name] = valueOrData{value: optional.Some(value)} + return b +} + +func (b *Builder) AddSecret(name string, value any) *Builder { + b.secrets[name] = valueOrData{value: optional.Some(value)} + return b +} + +func (b *Builder) AddDSN(name string, dbType DBType, dsn string) *Builder { + b.dsns[name] = dsnEntry{ + dbType: dbType, + dsn: dsn, + } + return b +} + +func (b *Builder) Build(ctx context.Context) (*ModuleContext, error) { + cm, err := newInMemoryConfigManager[cf.Configuration](ctx) + if err != nil { + return nil, err + } + sm, err := newInMemoryConfigManager[cf.Secrets](ctx) + if err != nil { + return nil, err + } + moduleCtx := &ModuleContext{ + configManager: cm, + secretsManager: sm, + dbProvider: NewDBProvider(), + } + + if err := buildConfigOrSecrets[cf.Configuration](ctx, *moduleCtx.configManager, b.configs, b.moduleName); err != nil { + return nil, err + } + if err := buildConfigOrSecrets[cf.Secrets](ctx, *moduleCtx.secretsManager, b.secrets, b.moduleName); err != nil { + return nil, err + } + for name, entry := range b.dsns { + if err = moduleCtx.dbProvider.Add(name, entry.dbType, entry.dsn); err != nil { + return nil, err + } + } + return moduleCtx, nil +} + +func newInMemoryConfigManager[R cf.Role](ctx context.Context) (*cf.Manager[R], error) { + provider := cf.NewInMemoryProvider[R]() + resolver := cf.NewInMemoryResolver[R]() + manager, err := cf.New(ctx, resolver, []cf.Provider[R]{provider}) + if err != nil { + return nil, err + } + return manager, nil +} + +func buildConfigOrSecrets[R cf.Role](ctx context.Context, manager cf.Manager[R], valueMap map[string]valueOrData, moduleName string) error { + for name, valueOrData := range valueMap { + if value, ok := valueOrData.value.Get(); ok { + if err := manager.Set(ctx, cf.Ref{Module: optional.Some(moduleName), Name: name}, value); err != nil { + return err + } + } else if data, ok := valueOrData.data.Get(); ok { + if err := manager.SetData(ctx, cf.Ref{Module: optional.Some(moduleName), Name: name}, data); err != nil { + return err + } + } else { + return fmt.Errorf("could not read value for name %q", name) + } + } + return nil +} diff --git a/go-runtime/modulecontext/builder_test.go b/go-runtime/modulecontext/builder_test.go new file mode 100644 index 0000000000..675f281b97 --- /dev/null +++ b/go-runtime/modulecontext/builder_test.go @@ -0,0 +1,43 @@ +package modulecontext + +import ( + "context" + "testing" + + "github.com/TBD54566975/ftl/internal/log" + "github.com/alecthomas/assert/v2" + "github.com/alecthomas/types/optional" + + cf "github.com/TBD54566975/ftl/common/configuration" +) + +func TestReadLatestValue(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + + moduleName := "test" + b := NewBuilder(moduleName) + b = b.AddConfig("c", "c-value1") + b = b.AddConfig("c", "c-value2") + b = b.AddSecret("s", "s-value1") + b = b.AddSecret("s", "s-value2") + b = b.AddDSN("d", DBTypePostgres, "postgres://localhost:54320/echo1?sslmode=disable&user=postgres&password=secret") + b = b.AddDSN("d", DBTypePostgres, "postgres://localhost:54320/echo2?sslmode=disable&user=postgres&password=secret") + moduleCtx, err := b.Build(ctx) + assert.NoError(t, err, "there should be no build errors") + ctx = moduleCtx.ApplyToContext(ctx) + + cm := cf.ConfigFromContext(ctx) + sm := cf.SecretsFromContext(ctx) + + var str string + + err = cm.Get(ctx, cf.Ref{Module: optional.Some(moduleName), Name: "c"}, &str) + assert.NoError(t, err, "could not read config value") + assert.Equal(t, "c-value2", str, "latest config value should be read") + + err = sm.Get(ctx, cf.Ref{Module: optional.Some(moduleName), Name: "s"}, &str) + assert.NoError(t, err, "could not read secret value") + assert.Equal(t, "s-value2", str, "latest secret value should be read") + + assert.Equal(t, moduleCtx.dbProvider.entries["d"].dsn, "postgres://localhost:54320/echo2?sslmode=disable&user=postgres&password=secret", "latest DSN should be read") +} diff --git a/go-runtime/modulecontext/modulecontext.go b/go-runtime/modulecontext/modulecontext.go index 0f9b2f2441..64858bc2cc 100644 --- a/go-runtime/modulecontext/modulecontext.go +++ b/go-runtime/modulecontext/modulecontext.go @@ -3,9 +3,7 @@ package modulecontext import ( "context" - ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" cf "github.com/TBD54566975/ftl/common/configuration" - "github.com/alecthomas/types/optional" ) // ModuleContext holds the context needed for a module, including configs, secrets and DSNs @@ -15,54 +13,6 @@ type ModuleContext struct { dbProvider *DBProvider } -func NewFromProto(ctx context.Context, moduleName string, response *ftlv1.ModuleContextResponse) (*ModuleContext, error) { - cm, err := newInMemoryConfigManager[cf.Configuration](ctx) - if err != nil { - return nil, err - } - sm, err := newInMemoryConfigManager[cf.Secrets](ctx) - if err != nil { - return nil, err - } - moduleCtx := &ModuleContext{ - configManager: cm, - secretsManager: sm, - dbProvider: NewDBProvider(), - } - - if err := addConfigOrSecrets[cf.Configuration](ctx, *moduleCtx.configManager, response.Configs, moduleName); err != nil { - return nil, err - } - if err := addConfigOrSecrets[cf.Secrets](ctx, *moduleCtx.secretsManager, response.Secrets, moduleName); err != nil { - return nil, err - } - for _, entry := range response.Databases { - if err = moduleCtx.dbProvider.Add(entry.Name, DBType(entry.Type), entry.Dsn); err != nil { - return nil, err - } - } - return moduleCtx, nil -} - -func newInMemoryConfigManager[R cf.Role](ctx context.Context) (*cf.Manager[R], error) { - provider := cf.NewInMemoryProvider[R]() - resolver := cf.NewInMemoryResolver[R]() - manager, err := cf.New(ctx, resolver, []cf.Provider[R]{provider}) - if err != nil { - return nil, err - } - return manager, nil -} - -func addConfigOrSecrets[R cf.Role](ctx context.Context, manager cf.Manager[R], valueMap map[string][]byte, moduleName string) error { - for name, data := range valueMap { - if err := manager.SetData(ctx, cf.Ref{Module: optional.Some(moduleName), Name: name}, data); err != nil { - return err - } - } - return nil -} - // ApplyToContext sets up the context so that configurations, secrets and DSNs can be retreived // Each of these components have accessors to get a manager back from the context func (m *ModuleContext) ApplyToContext(ctx context.Context) context.Context { diff --git a/go-runtime/server/server.go b/go-runtime/server/server.go index b305d97645..f8f8f02ce5 100644 --- a/go-runtime/server/server.go +++ b/go-runtime/server/server.go @@ -40,7 +40,7 @@ func NewUserVerbServer(moduleName string, handlers ...Handler) plugin.Constructo if err != nil { return nil, nil, fmt.Errorf("could not get config: %w", err) } - moduleCtx, err := modulecontext.NewFromProto(ctx, moduleName, resp.Msg) + moduleCtx, err := modulecontext.NewBuilderFromProto(moduleName, resp.Msg).Build(ctx) if err != nil { return nil, nil, err }