From bc5d4905c1135b9ccc666ce899e6d1d31ea075be Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 13 Jun 2024 09:58:32 +1000 Subject: [PATCH] fix: require an ftl-project.toml file This is a global requirement across all commands, not scoped specifically to `ftl serve` or `ftl dev`, because eg. `ftl secret`/`ftl config`, etc. etc. all require this config file, and it's just cleaner/safer to do it in one location. Fixes #1669 --- backend/controller/admin/admin.go | 4 ++-- cmd/ftl/cmd_secret.go | 14 +++++++++----- cmd/ftl/main.go | 17 +++++++++++++--- common/configuration/manager.go | 28 +++++++++++++++++++++------ common/projectconfig/projectconfig.go | 22 +++++++++++++++++---- examples/go/echo/go.mod | 2 +- integration/actions.go | 2 +- integration/harness.go | 3 +++ 8 files changed, 70 insertions(+), 22 deletions(-) diff --git a/backend/controller/admin/admin.go b/backend/controller/admin/admin.go index 7590a2175e..e33797aa16 100644 --- a/backend/controller/admin/admin.go +++ b/backend/controller/admin/admin.go @@ -99,7 +99,7 @@ func configProviderKey(p *ftlv1.ConfigProvider) string { // ConfigSet sets the configuration at the given ref to the provided value. func (s *AdminService) ConfigSet(ctx context.Context, req *connect.Request[ftlv1.SetConfigRequest]) (*connect.Response[ftlv1.SetConfigResponse], error) { pkey := configProviderKey(req.Msg.Provider) - err := s.cm.Set(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), string(req.Msg.Value)) + err := s.cm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value) if err != nil { return nil, err } @@ -185,7 +185,7 @@ func secretProviderKey(p *ftlv1.SecretProvider) string { // SecretSet sets the secret at the given ref to the provided value. func (s *AdminService) SecretSet(ctx context.Context, req *connect.Request[ftlv1.SetSecretRequest]) (*connect.Response[ftlv1.SetSecretResponse], error) { pkey := secretProviderKey(req.Msg.Provider) - err := s.sm.Set(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), string(req.Msg.Value)) + err := s.sm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value) if err != nil { return nil, err } diff --git a/cmd/ftl/cmd_secret.go b/cmd/ftl/cmd_secret.go index 197dd94745..c551f2d271 100644 --- a/cmd/ftl/cmd_secret.go +++ b/cmd/ftl/cmd_secret.go @@ -111,7 +111,6 @@ func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, adminClient adm var err error var secret []byte if isatty.IsTerminal(0) { - fmt.Print("Secret: ") secret, err = term.ReadPassword(0) fmt.Println() if err != nil { @@ -124,18 +123,23 @@ func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, adminClient adm } } - var secretValue []byte + var secretJSON json.RawMessage if s.JSON { - if err := json.Unmarshal(secret, &secretValue); err != nil { + var jsonValue any + if err := json.Unmarshal(secret, &jsonValue); err != nil { return fmt.Errorf("secret is not valid JSON: %w", err) } + secretJSON = secret } else { - secretValue = secret + secretJSON, err = json.Marshal(string(secret)) + if err != nil { + return fmt.Errorf("failed to encode secret as JSON: %w", err) + } } req := &ftlv1.SetSecretRequest{ Ref: configRefFromRef(s.Ref), - Value: secretValue, + Value: secretJSON, } if provider, ok := scmd.provider().Get(); ok { req.Provider = &provider diff --git a/cmd/ftl/main.go b/cmd/ftl/main.go index 9ef5764794..8dfae4c29c 100644 --- a/cmd/ftl/main.go +++ b/cmd/ftl/main.go @@ -84,14 +84,25 @@ func main() { logger := log.Configure(os.Stderr, cli.LogConfig) ctx = log.ContextWithLogger(ctx, logger) - config, err := projectconfig.Load(ctx, cli.ConfigFlag) + configPath := cli.ConfigFlag + if configPath == "" { + var ok bool + configPath, ok = projectconfig.DefaultConfigPath().Get() + if !ok { + kctx.Fatalf("could not determine default config path, place an ftl-project.toml file in your git root directory, use --config=FILE, or set the FTL_CONFIG envar") + } + } + + os.Setenv("FTL_CONFIG", configPath) + + config, err := projectconfig.Load(ctx, configPath) if err != nil && !errors.Is(err, os.ErrNotExist) { kctx.Fatalf(err.Error()) } kctx.Bind(config) - sr := cf.ProjectConfigResolver[cf.Secrets]{Config: cli.ConfigFlag} - cr := cf.ProjectConfigResolver[cf.Configuration]{Config: cli.ConfigFlag} + sr := cf.ProjectConfigResolver[cf.Secrets]{Config: configPath} + cr := cf.ProjectConfigResolver[cf.Configuration]{Config: configPath} kctx.BindTo(sr, (*cf.Resolver[cf.Secrets])(nil)) kctx.BindTo(cr, (*cf.Resolver[cf.Configuration])(nil)) diff --git a/common/configuration/manager.go b/common/configuration/manager.go index 97c4d5df72..3763f71f24 100644 --- a/common/configuration/manager.go +++ b/common/configuration/manager.go @@ -1,6 +1,7 @@ package configuration import ( + "bytes" "context" "encoding/json" "errors" @@ -108,18 +109,26 @@ func (m *Manager[R]) availableProviderKeys() []string { return keys } -// Set a configuration value. +// Set a configuration value, encoding "value" as JSON before storing it. func (m *Manager[R]) Set(ctx context.Context, pkey string, ref Ref, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + return m.SetJSON(ctx, pkey, ref, data) +} + +// SetJSON sets a configuration value using raw JSON data. +func (m *Manager[R]) SetJSON(ctx context.Context, pkey string, ref Ref, value json.RawMessage) error { + if err := checkJSON(value); err != nil { + return fmt.Errorf("invalid value for %s, must be JSON: %w", m.resolver.Role(), err) + } provider, ok := m.providers[pkey] if !ok { pkeys := strings.Join(m.availableProviderKeys(), ", ") return fmt.Errorf("no provider for key %q, specify one of: %s", pkey, pkeys) } - data, err := json.Marshal(value) - if err != nil { - return err - } - key, err := provider.Store(ctx, ref, data) + key, err := provider.Store(ctx, ref, value) if err != nil { return err } @@ -173,3 +182,10 @@ func (m *Manager[R]) Unset(ctx context.Context, pkey string, ref Ref) error { func (m *Manager[R]) List(ctx context.Context) ([]Entry, error) { return m.resolver.List(ctx) } + +func checkJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var v any + return dec.Decode(&v) +} diff --git a/common/projectconfig/projectconfig.go b/common/projectconfig/projectconfig.go index a0cde56aee..64f1d004b0 100644 --- a/common/projectconfig/projectconfig.go +++ b/common/projectconfig/projectconfig.go @@ -12,7 +12,6 @@ import ( "github.com/alecthomas/types/optional" "github.com/TBD54566975/ftl" - "github.com/TBD54566975/ftl/internal" "github.com/TBD54566975/ftl/internal/log" ) @@ -76,11 +75,26 @@ func DefaultConfigPath() optional.Option[string] { } return optional.Some(absPath) } - gitRoot, ok := internal.GitRoot("").Get() - if !ok { + dir, err := os.Getwd() + if err != nil { return optional.None[string]() } - return optional.Some(filepath.Join(gitRoot, "ftl-project.toml")) + // Find the first ftl-project.toml file in the parent directories. + for { + path := filepath.Join(dir, "ftl-project.toml") + _, err := os.Stat(path) + if err == nil { + return optional.Some(path) + } + if !errors.Is(err, os.ErrNotExist) { + return optional.None[string]() + } + dir = filepath.Dir(dir) + if dir == "/" || dir == "." { + break + } + } + return optional.Some(filepath.Join(dir, "ftl-project.toml")) } // MaybeCreateDefault creates the ftl-project.toml file in the Git root if it diff --git a/examples/go/echo/go.mod b/examples/go/echo/go.mod index 7e6e878c8c..7777ec9a6d 100644 --- a/examples/go/echo/go.mod +++ b/examples/go/echo/go.mod @@ -4,7 +4,7 @@ go 1.22.2 replace github.com/TBD54566975/ftl => ../../.. -require github.com/TBD54566975/ftl v0.241.2 +require github.com/TBD54566975/ftl v0.248.0 require ( connectrpc.com/connect v1.16.1 // indirect diff --git a/integration/actions.go b/integration/actions.go index 5dbb734947..2d11480a31 100644 --- a/integration/actions.go +++ b/integration/actions.go @@ -114,7 +114,7 @@ func DebugShell() Action { // Exec runs a command from the test working directory. func Exec(cmd string, args ...string) Action { return func(t testing.TB, ic TestContext) { - Infof("Executing: %s %s", cmd, shellquote.Join(args...)) + Infof("Executing (in %s): %s %s", ic.workDir, cmd, shellquote.Join(args...)) err := ftlexec.Command(ic, log.Debug, ic.workDir, cmd, args...).RunBuffered(ic) assert.NoError(t, err) } diff --git a/integration/harness.go b/integration/harness.go index 0b7e8b3a81..b47a57be9f 100644 --- a/integration/harness.go +++ b/integration/harness.go @@ -78,6 +78,9 @@ func run(t *testing.T, ftlConfigPath string, startController bool, actions ...Ac // can't be loaded until the module is copied over, and the config itself // is used by FTL during startup. t.Setenv("FTL_CONFIG", filepath.Join(cwd, "testdata", "go", ftlConfigPath)) + } else { + err = os.WriteFile(filepath.Join(tmpDir, "ftl-project.toml"), []byte{}, 0644) + assert.NoError(t, err) } // Build FTL binary