From 88881260ead360bb125b0530d5606a8989f3d1d6 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Fri, 27 Sep 2024 15:39:48 +1000 Subject: [PATCH] feat: add "ftl profile ..." command tree (#2862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also refactor profiles package a bit to be more flexible. ``` 🐚 ~/dev/ftl $ ftl profile init ftl Project initialized in /Users/alec/dev/ftl. 🐚 ~/dev/ftl $ ftl profile new --local test 🐚 ~/dev/ftl $ ftl profile list local (local, default) test (local) 🐚 ~/dev/ftl $ ftl profile switch test 🐚 ~/dev/ftl $ ftl profile list local (local, default) test (local, active) ``` --------- Co-authored-by: github-actions[bot] --- .gitignore | 2 +- frontend/cli/cmd_init.go | 57 +++--- frontend/cli/cmd_profile.go | 133 ++++++++++++++ frontend/cli/main.go | 25 ++- internal/profiles/internal/persistence.go | 73 +++++++- internal/profiles/profiles.go | 214 +++++++++++++++++++--- internal/profiles/profiles_test.go | 19 +- 7 files changed, 458 insertions(+), 65 deletions(-) create mode 100644 frontend/cli/cmd_profile.go diff --git a/.gitignore b/.gitignore index 9878a46aa8..638d7306ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -/.ftl-project/ .hermit/ .vscode/* !/.vscode/settings.json @@ -42,3 +41,4 @@ junit*.xml /docs/public .ftl.lock docker-build/ +**/.ftl diff --git a/frontend/cli/cmd_init.go b/frontend/cli/cmd_init.go index 3577f9d056..c1a2f77e66 100644 --- a/frontend/cli/cmd_init.go +++ b/frontend/cli/cmd_init.go @@ -9,28 +9,31 @@ import ( "strings" "github.com/TBD54566975/ftl" - "github.com/TBD54566975/ftl/internal" + "github.com/TBD54566975/ftl/internal/configuration" + "github.com/TBD54566975/ftl/internal/configuration/providers" "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" + "github.com/TBD54566975/ftl/internal/profiles" "github.com/TBD54566975/ftl/internal/projectconfig" "github.com/TBD54566975/ftl/internal/projectinit" ) type initCmd struct { - Name string `arg:"" help:"Name of the project."` - Hermit bool `help:"Include Hermit language-specific toolchain binaries." negatable:""` - Dir string `arg:"" help:"Directory to initialize the project in."` - ModuleDirs []string `help:"Child directories of existing modules."` - NoGit bool `help:"Don't add files to the git repository."` - Startup string `help:"Command to run on startup."` + Name string `arg:"" help:"Name of the project."` + Hermit bool `help:"Include Hermit language-specific toolchain binaries." negatable:""` + Dir string `arg:"" help:"Directory to initialize the project in." default:"${gitroot}" required:""` + ModuleDirs []string `help:"Child directories of existing modules."` + ModuleRoots []string `help:"Root directories of existing modules."` + NoGit bool `help:"Don't add files to the git repository."` + Startup string `help:"Command to run on startup."` } -func (i initCmd) Run(ctx context.Context) error { - if i.Dir == "" { - return fmt.Errorf("directory is required") - } - - logger := log.FromContext(ctx) +func (i initCmd) Run( + ctx context.Context, + logger *log.Logger, + configRegistry *providers.Registry[configuration.Configuration], + secretsRegistry *providers.Registry[configuration.Secrets], +) error { logger.Debugf("Initializing FTL project in %s", i.Dir) if err := scaffold(ctx, i.Hermit, projectinit.Files(), i.Dir, i); err != nil { return err @@ -50,20 +53,24 @@ func (i initCmd) Run(ctx context.Context) error { return err } - gitRoot, ok := internal.GitRoot(i.Dir).Get() - if !i.NoGit && ok { + _, err := profiles.Init(profiles.ProjectConfig{ + Realm: i.Name, + FTLMinVersion: ftl.Version, + ModuleRoots: i.ModuleRoots, + NoGit: i.NoGit, + Root: i.Dir, + }, secretsRegistry, configRegistry) + if err != nil { + return fmt.Errorf("initialize project: %w", err) + } + + if !i.NoGit { logger.Debugf("Updating .gitignore") - if err := updateGitIgnore(ctx, gitRoot); err != nil { - return err - } - logger.Debugf("Adding files to git") - if i.Hermit { - if err := maybeGitAdd(ctx, i.Dir, "bin/*"); err != nil { - return err - } + if err := updateGitIgnore(ctx, i.Dir); err != nil { + return fmt.Errorf("update .gitignore: %w", err) } - if err := maybeGitAdd(ctx, i.Dir, "ftl-project.toml"); err != nil { - return err + if err := maybeGitAdd(ctx, i.Dir, ".ftl-project"); err != nil { + return fmt.Errorf("git add .ftl-project: %w", err) } } return nil diff --git a/frontend/cli/cmd_profile.go b/frontend/cli/cmd_profile.go new file mode 100644 index 0000000000..3be6613104 --- /dev/null +++ b/frontend/cli/cmd_profile.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "net/url" + "strings" + + "github.com/alecthomas/types/either" + + "github.com/TBD54566975/ftl" + "github.com/TBD54566975/ftl/internal/configuration" + "github.com/TBD54566975/ftl/internal/configuration/providers" + "github.com/TBD54566975/ftl/internal/profiles" +) + +type profileCmd struct { + Init profileInitCmd `cmd:"" help:"Initialize a new project."` + List profileListCmd `cmd:"" help:"List all profiles."` + Default profileDefaultCmd `cmd:"" help:"Set a profile as default."` + Switch profileSwitchCmd `cmd:"" help:"Switch locally active profile."` + New profileNewCmd `cmd:"" help:"Create a new profile."` +} + +type profileInitCmd struct { + Project string `arg:"" help:"Name of the project."` + Dir string `arg:"" help:"Directory to initialize the project in." default:"${gitroot}" required:""` + ModuleRoots []string `help:"Root directories of existing modules."` + NoGit bool `help:"Don't add files to the git repository."` +} + +func (p profileInitCmd) Run( + configRegistry *providers.Registry[configuration.Configuration], + secretsRegistry *providers.Registry[configuration.Secrets], +) error { + _, err := profiles.Init(profiles.ProjectConfig{ + Realm: p.Project, + FTLMinVersion: ftl.Version, + ModuleRoots: p.ModuleRoots, + NoGit: p.NoGit, + Root: p.Dir, + }, secretsRegistry, configRegistry) + if err != nil { + return fmt.Errorf("init project: %w", err) + } + fmt.Printf("Project initialized in %s.\n", p.Dir) + return nil +} + +type profileListCmd struct{} + +func (profileListCmd) Run(project *profiles.Project) error { + active, err := project.ActiveProfile() + if err != nil { + return fmt.Errorf("active profile: %w", err) + } + p, err := project.List() + if err != nil { + return fmt.Errorf("list profiles: %w", err) + } + for _, profile := range p { + attrs := []string{} + switch profile.Config.(type) { + case either.Left[profiles.LocalProfileConfig, profiles.RemoteProfileConfig]: + attrs = append(attrs, "local") + case either.Right[profiles.LocalProfileConfig, profiles.RemoteProfileConfig]: + attrs = append(attrs, "remote") + } + if project.DefaultProfile() == profile.Name { + attrs = append(attrs, "default") + } + if active == profile.Name { + attrs = append(attrs, "active") + } + fmt.Printf("%s (%s)\n", profile, strings.Join(attrs, ", ")) + } + return nil +} + +type profileDefaultCmd struct { + Profile string `arg:"" help:"Profile name."` +} + +func (p profileDefaultCmd) Run(project *profiles.Project) error { + err := project.SetDefault(p.Profile) + if err != nil { + return fmt.Errorf("set default profile: %w", err) + } + return nil +} + +type profileSwitchCmd struct { + Profile string `arg:"" help:"Profile name."` +} + +func (p profileSwitchCmd) Run(project *profiles.Project) error { + err := project.Switch(p.Profile) + if err != nil { + return fmt.Errorf("switch profile: %w", err) + } + return nil +} + +type profileNewCmd struct { + Local bool `help:"Create a local profile." xor:"location" and:"providers"` + Remote *url.URL `help:"Create a remote profile." xor:"location"` + Secrets configuration.ProviderKey `help:"Secrets provider." placeholder:"PROVIDER" default:"inline" and:"providers"` + Configuration configuration.ProviderKey `help:"Configuration provider." placeholder:"PROVIDER" default:"inline" and:"providers"` + Name string `arg:"" help:"Profile name."` +} + +func (p profileNewCmd) Run(project *profiles.Project) error { + var config either.Either[profiles.LocalProfileConfig, profiles.RemoteProfileConfig] + switch { + case p.Local: + config = either.LeftOf[profiles.RemoteProfileConfig](profiles.LocalProfileConfig{ + SecretsProvider: p.Secrets, + ConfigProvider: p.Configuration, + }) + + case p.Remote != nil: + config = either.RightOf[profiles.LocalProfileConfig](profiles.RemoteProfileConfig{ + Endpoint: p.Remote, + }) + } + err := project.New(profiles.ProfileConfig{ + Name: p.Name, + Config: config, + }) + if err != nil { + return fmt.Errorf("new profile: %w", err) + } + return nil +} diff --git a/frontend/cli/main.go b/frontend/cli/main.go index 60e018d1a3..1e3f8c9411 100644 --- a/frontend/cli/main.go +++ b/frontend/cli/main.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "os/signal" + "path/filepath" "runtime" "strconv" "syscall" @@ -16,8 +17,12 @@ import ( "github.com/TBD54566975/ftl" "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" + "github.com/TBD54566975/ftl/internal" _ "github.com/TBD54566975/ftl/internal/automaxprocs" // Set GOMAXPROCS to match Linux container CPU quota. + "github.com/TBD54566975/ftl/internal/configuration" + "github.com/TBD54566975/ftl/internal/configuration/providers" "github.com/TBD54566975/ftl/internal/log" + "github.com/TBD54566975/ftl/internal/profiles" "github.com/TBD54566975/ftl/internal/projectconfig" "github.com/TBD54566975/ftl/internal/rpc" "github.com/TBD54566975/ftl/internal/terminal" @@ -30,6 +35,7 @@ type InteractiveCLI struct { Ping pingCmd `cmd:"" help:"Ping the FTL cluster."` Status statusCmd `cmd:"" help:"Show FTL status."` Init initCmd `cmd:"" help:"Initialize a new FTL project."` + Profile profileCmd `cmd:"" help:"Manage profiles."` New newCmd `cmd:"" help:"Create a new FTL module."` PS psCmd `cmd:"" help:"List deployments."` Call callCmd `cmd:"" help:"Call an FTL function."` @@ -121,7 +127,7 @@ func main() { if err != nil && !errors.Is(err, os.ErrNotExist) { kctx.FatalIfErrorf(err) } - bindContext := makeBindContext(config, cancel) + bindContext := makeBindContext(config, logger, cancel) ctx = bindContext(ctx, kctx) err = kctx.Run(ctx) @@ -129,6 +135,7 @@ func main() { } func createKongApplication(cli any) *kong.Kong { + gitRoot, _ := internal.GitRoot(".").Get() app := kong.Must(cli, kong.Description(`FTL - Towards a 𝝺-calculus for large-scale systems`), kong.Configuration(kongtoml.Loader, ".ftl.toml", "~/.ftl.toml"), @@ -146,25 +153,39 @@ func createKongApplication(cli any) *kong.Kong { "os": runtime.GOOS, "arch": runtime.GOARCH, "numcpu": strconv.Itoa(runtime.NumCPU()), + "gitroot": gitRoot, }, ) return app } -func makeBindContext(projectConfig projectconfig.Config, cancel context.CancelFunc) terminal.KongContextBinder { +func makeBindContext(projectConfig projectconfig.Config, logger *log.Logger, cancel context.CancelFunc) terminal.KongContextBinder { var bindContext terminal.KongContextBinder bindContext = func(ctx context.Context, kctx *kong.Context) context.Context { kctx.Bind(projectConfig) + kctx.Bind(logger) controllerServiceClient := rpc.Dial(ftlv1connect.NewControllerServiceClient, cli.Endpoint.String(), log.Error) ctx = rpc.ContextWithClient(ctx, controllerServiceClient) kctx.BindTo(controllerServiceClient, (*ftlv1connect.ControllerServiceClient)(nil)) + // Initialise configuration registries. + configRegistry := providers.NewRegistry[configuration.Configuration]() + configRegistry.Register(providers.NewEnvarFactory[configuration.Configuration]()) + configRegistry.Register(providers.NewInlineFactory[configuration.Configuration]()) + kctx.Bind(configRegistry) + secretsRegistry := providers.NewRegistry[configuration.Secrets]() + secretsRegistry.Register(providers.NewEnvarFactory[configuration.Secrets]()) + secretsRegistry.Register(providers.NewInlineFactory[configuration.Secrets]()) kongcompletion.Register(kctx.Kong, kongcompletion.WithPredictors(terminal.Predictors(ctx, controllerServiceClient))) + kctx.Bind(secretsRegistry) verbServiceClient := rpc.Dial(ftlv1connect.NewVerbServiceClient, cli.Endpoint.String(), log.Error) ctx = rpc.ContextWithClient(ctx, verbServiceClient) kctx.BindTo(verbServiceClient, (*ftlv1connect.VerbServiceClient)(nil)) + project, err := profiles.Open(filepath.Dir(projectConfig.Path), secretsRegistry, configRegistry) + kctx.FatalIfErrorf(err) + kctx.Bind(project) kctx.Bind(cli.Endpoint) kctx.BindTo(ctx, (*context.Context)(nil)) diff --git a/internal/profiles/internal/persistence.go b/internal/profiles/internal/persistence.go index 784dc6b80f..6a487c4265 100644 --- a/internal/profiles/internal/persistence.go +++ b/internal/profiles/internal/persistence.go @@ -22,9 +22,11 @@ import ( "net/url" "os" "path/filepath" + "sort" + "strings" "github.com/TBD54566975/ftl/internal/configuration" - "github.com/TBD54566975/ftl/internal/slices" + "github.com/TBD54566975/ftl/internal/sha256" ) type ProfileType string @@ -60,14 +62,67 @@ type Project struct { Root string `json:"-"` } -// Profiles returns the names of all profiles in the project. -func (p Project) Profiles() ([]string, error) { +// ActiveProfile returns the name of the active profile. +// +// If no profile is active, it returns the default. +func (p Project) ActiveProfile() (string, error) { + cacheDir, err := p.ensureUserProjectDir() + if err != nil { + return "", err + } + profile, err := os.ReadFile(filepath.Join(cacheDir, "active-profile")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", fmt.Errorf("read active profile: %w", err) + } + return strings.TrimSpace(string(profile)), nil +} + +func (p Project) SetActiveProfile(profile string) error { + cacheDir, err := p.ensureUserProjectDir() + if err != nil { + return err + } + err = os.WriteFile(filepath.Join(cacheDir, "active-profile"), []byte(profile), 0600) + if err != nil { + return fmt.Errorf("write active profile: %w", err) + } + return nil +} + +func (p Project) ensureUserProjectDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("user cache dir: %w", err) + } + + cacheDir = filepath.Join(cacheDir, "ftl-projects", sha256.Sum([]byte(p.Root)).String()) + if err = os.MkdirAll(cacheDir, 0700); err != nil { + return "", fmt.Errorf("mkdir cache dir: %w", err) + } + return cacheDir, nil +} + +// ListProfiles returns the names of all profiles in the project. +func (p Project) ListProfiles() ([]Profile, error) { profileDir := filepath.Join(p.Root, ".ftl-project", "profiles") profiles, err := filepath.Glob(filepath.Join(profileDir, "*", "profile.json")) if err != nil { return nil, fmt.Errorf("profiles: %s: %w", profileDir, err) } - return slices.Map(profiles, func(p string) string { return filepath.Base(filepath.Dir(p)) }), nil + out := make([]Profile, 0, len(profiles)) + for _, profile := range profiles { + name := filepath.Base(filepath.Dir(profile)) + profile, err := p.LoadProfile(name) + if err != nil { + return nil, fmt.Errorf("%s: load profile: %w", name, err) + } + out = append(out, profile) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil } func (p Project) LoadProfile(name string) (Profile, error) { @@ -108,7 +163,7 @@ func (p Project) SaveProfile(profile Profile) error { return nil } -func (p *Project) Save() error { +func (p Project) Save() error { profilePath := filepath.Join(p.Root, ".ftl-project", "project.json") if err := os.MkdirAll(filepath.Dir(profilePath), 0700); err != nil { return fmt.Errorf("mkdir %s: %w", filepath.Dir(profilePath), err) @@ -129,12 +184,12 @@ func (p *Project) Save() error { } // LocalSecretsPath returns the path to the secrets file for the given local profile. -func (p *Project) LocalSecretsPath(profile string) string { +func (p Project) LocalSecretsPath(profile string) string { return filepath.Join(p.Root, ".ftl-project", "profiles", profile, "secrets.json") } // LocalConfigPath returns the path to the config file for the given local profile. -func (p *Project) LocalConfigPath(profile string) string { +func (p Project) LocalConfigPath(profile string) string { return filepath.Join(p.Root, ".ftl-project", "profiles", profile, "config.json") } @@ -177,6 +232,10 @@ func Init(project Project) error { // Load the project configuration from the given root directory. func Load(root string) (Project, error) { + root, err := filepath.Abs(root) + if err != nil { + return Project{}, fmt.Errorf("failed to get absolute path: %w", err) + } profilePath := filepath.Join(root, ".ftl-project", "project.json") r, err := os.Open(profilePath) if errors.Is(err, os.ErrNotExist) { diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index e7d488569b..6b6bfc4347 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -2,14 +2,20 @@ package profiles import ( "context" + "errors" "fmt" "net/url" + "os" + + "github.com/alecthomas/types/either" "github.com/TBD54566975/ftl/internal/configuration" "github.com/TBD54566975/ftl/internal/configuration/manager" "github.com/TBD54566975/ftl/internal/configuration/providers" "github.com/TBD54566975/ftl/internal/configuration/routers" "github.com/TBD54566975/ftl/internal/profiles/internal" + "github.com/TBD54566975/ftl/internal/reflect" + "github.com/TBD54566975/ftl/internal/slices" ) type ProjectConfig internal.Project @@ -26,37 +32,201 @@ type Profile struct { cm *manager.Manager[configuration.Configuration] } -// ProjectConfig is static project-wide configuration shared by all profiles. +// ProjectConfig is the static project-wide configuration shared by all profiles. func (p *Profile) ProjectConfig() ProjectConfig { return p.shared } // Config is the static configuration for a Profile. -func (p *Profile) Config() Config { return p.config } +func (p *Profile) Config() Config { return reflect.DeepCopy(p.config) } + +// SecretsManager returns the secrets manager for this profile. +func (p *Profile) SecretsManager() *manager.Manager[configuration.Secrets] { return p.sm } -func (p *Profile) SecretsManager() *manager.Manager[configuration.Secrets] { return p.sm } +// ConfigurationManager returns the configuration manager for this profile. func (p *Profile) ConfigurationManager() *manager.Manager[configuration.Configuration] { return p.cm } -// Init a new project with a default "local" profile. -func Init(project ProjectConfig) error { - err := internal.Init(internal.Project(project)) +type LocalProfileConfig struct { + SecretsProvider configuration.ProviderKey + ConfigProvider configuration.ProviderKey +} + +type RemoteProfileConfig struct { + Endpoint *url.URL +} + +type ProfileConfig struct { + Name string + Config either.Either[LocalProfileConfig, RemoteProfileConfig] +} + +func (p ProfileConfig) String() string { return p.Name } + +type Project struct { + project internal.Project + secretsRegistry *providers.Registry[configuration.Secrets] + configRegistry *providers.Registry[configuration.Configuration] +} + +// Open a project. +func Open( + root string, + secretsRegistry *providers.Registry[configuration.Secrets], + configRegistry *providers.Registry[configuration.Configuration], +) (*Project, error) { + project, err := internal.Load(root) if err != nil { - return fmt.Errorf("init project: %w", err) + return nil, fmt.Errorf("open project: %w", err) } - return nil + return &Project{ + project: project, + secretsRegistry: secretsRegistry, + configRegistry: configRegistry, + }, nil } -// Load a profile from the project. -func Load( - ctx context.Context, +// Init a new project with a default local profile. +// +// If "project.Default" is empty a new project will be created with a default "local" profile. +func Init( + project ProjectConfig, secretsRegistry *providers.Registry[configuration.Secrets], configRegistry *providers.Registry[configuration.Configuration], - root string, - profile string, -) (Profile, error) { - project, err := internal.Load(root) +) (*Project, error) { + err := internal.Init(internal.Project(project)) if err != nil { - return Profile{}, fmt.Errorf("load project: %w", err) + return nil, fmt.Errorf("init project: %w", err) } - prof, err := project.LoadProfile(profile) + return &Project{ + project: internal.Project(project), + secretsRegistry: secretsRegistry, + configRegistry: configRegistry, + }, nil +} + +// SetDefault profile for the project. +func (p *Project) SetDefault(profile string) error { + _, err := p.project.LoadProfile(profile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s: profile does not exist", profile) + } + return fmt.Errorf("%s: load profile: %w", profile, err) + } + p.project.DefaultProfile = profile + err = p.project.Save() + if err != nil { + return fmt.Errorf("%s: save project: %w", profile, err) + } + return nil +} + +// Switch active profiles. +func (p *Project) Switch(profile string) error { + _, err := p.project.LoadProfile(profile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s: profile does not exist", profile) + } + return fmt.Errorf("%s: load profile: %w", profile, err) + } + err = p.project.SetActiveProfile(profile) + if err != nil { + return fmt.Errorf("set active profile: %w", err) + } + return nil +} + +// ActiveProfile returns the name of the active profile. +// +// If no profile is active, the default profile is returned. +func (p *Project) ActiveProfile() (string, error) { + profile, err := p.project.ActiveProfile() + if err != nil { + return "", fmt.Errorf("active profile: %w", err) + } + return profile, nil +} + +// DefaultProfile returns the name of the default profile. +func (p *Project) DefaultProfile() string { + return p.project.DefaultProfile +} + +// List all profiles in the project. +func (p *Project) List() ([]ProfileConfig, error) { + profiles, err := p.project.ListProfiles() + if err != nil { + return nil, fmt.Errorf("load profiles: %w", err) + } + configs, err := slices.MapErr(profiles, func(profile internal.Profile) (ProfileConfig, error) { + var config either.Either[LocalProfileConfig, RemoteProfileConfig] + switch profile.Type { + case internal.ProfileTypeLocal: + config = either.LeftOf[RemoteProfileConfig](LocalProfileConfig{ + SecretsProvider: profile.SecretsProvider, + ConfigProvider: profile.ConfigProvider, + }) + case internal.ProfileTypeRemote: + endpoint, err := profile.EndpointURL() + if err != nil { + return ProfileConfig{}, fmt.Errorf("profile endpoint: %w", err) + } + config = either.RightOf[LocalProfileConfig](RemoteProfileConfig{ + Endpoint: endpoint, + }) + } + return ProfileConfig{ + Name: profile.Name, + Config: config, + }, nil + }) + if err != nil { + return nil, fmt.Errorf("map profiles: %w", err) + } + return configs, nil +} + +// New creates a new profile in the project. +func (p *Project) New(profileConfig ProfileConfig) error { + _, err := p.project.LoadProfile(profileConfig.Name) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("load profile: %w", err) + } + } else { + return fmt.Errorf("profile %s already exists", profileConfig.Name) + } + var profile internal.Profile + switch handle := profileConfig.Config.(type) { + case either.Left[LocalProfileConfig, RemoteProfileConfig]: + config := handle.Get() + profile = internal.Profile{ + Name: profileConfig.Name, + Type: internal.ProfileTypeLocal, + SecretsProvider: config.SecretsProvider, + ConfigProvider: config.ConfigProvider, + } + + case either.Right[LocalProfileConfig, RemoteProfileConfig]: + config := handle.Get() + profile = internal.Profile{ + Name: profileConfig.Name, + Endpoint: config.Endpoint.String(), + Type: internal.ProfileTypeRemote, + } + + case nil: + return fmt.Errorf("profile config is nil") + } + err = p.project.SaveProfile(profile) + if err != nil { + return fmt.Errorf("save profile: %w", err) + } + return nil +} + +// Load a profile from the project. +func (p *Project) Load(ctx context.Context, profile string) (Profile, error) { + prof, err := p.project.LoadProfile(profile) if err != nil { return Profile{}, fmt.Errorf("load profile: %w", err) } @@ -69,21 +239,21 @@ func Load( var cm *manager.Manager[configuration.Configuration] switch prof.Type { case internal.ProfileTypeLocal: - sp, err := secretsRegistry.Get(ctx, prof.SecretsProvider) + sp, err := p.secretsRegistry.Get(ctx, prof.SecretsProvider) if err != nil { return Profile{}, fmt.Errorf("get secrets provider: %w", err) } - secretsRouter := routers.NewFileRouter[configuration.Secrets](project.LocalSecretsPath(profile)) + secretsRouter := routers.NewFileRouter[configuration.Secrets](p.project.LocalSecretsPath(profile)) sm, err = manager.New[configuration.Secrets](ctx, secretsRouter, []configuration.Provider[configuration.Secrets]{sp}) if err != nil { return Profile{}, fmt.Errorf("create secrets manager: %w", err) } - cp, err := configRegistry.Get(ctx, prof.ConfigProvider) + cp, err := p.configRegistry.Get(ctx, prof.ConfigProvider) if err != nil { return Profile{}, fmt.Errorf("get config provider: %w", err) } - configRouter := routers.NewFileRouter[configuration.Configuration](project.LocalConfigPath(profile)) + configRouter := routers.NewFileRouter[configuration.Configuration](p.project.LocalConfigPath(profile)) cm, err = manager.New[configuration.Configuration](ctx, configRouter, []configuration.Provider[configuration.Configuration]{cp}) if err != nil { return Profile{}, fmt.Errorf("create configuration manager: %w", err) @@ -96,7 +266,7 @@ func Load( return Profile{}, fmt.Errorf("%s: unknown profile type: %q", profile, prof.Type) } return Profile{ - shared: ProjectConfig(project), + shared: ProjectConfig(p.project), config: Config{ Name: prof.Name, Endpoint: profileEndpoint, diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index f09b6a943f..c50a70c09a 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -18,28 +18,31 @@ func TestProfile(t *testing.T) { root := t.TempDir() ctx := context.Background() - project := profiles.ProjectConfig{ + projectConfig := profiles.ProjectConfig{ Root: root, Realm: "test", FTLMinVersion: ftl.Version, ModuleRoots: []string{"."}, NoGit: true, } - err := profiles.Init(project) - assert.NoError(t, err) - sr := providers.NewRegistry[configuration.Secrets]() sr.Register(providers.NewInlineFactory[configuration.Secrets]()) cr := providers.NewRegistry[configuration.Configuration]() cr.Register(providers.NewInlineFactory[configuration.Configuration]()) - local, err := profiles.Load(ctx, sr, cr, root, "local") + _, err := profiles.Init(projectConfig, sr, cr) + assert.NoError(t, err) + + project, err := profiles.Open(root, sr, cr) + assert.NoError(t, err) + + profile, err := project.Load(ctx, "local") assert.NoError(t, err) assert.Equal(t, profiles.Config{ Name: "local", Endpoint: must.Get(url.Parse("http://localhost:8892")), - }, local.Config()) + }, profile.Config()) assert.Equal(t, profiles.ProjectConfig{ Root: root, @@ -48,9 +51,9 @@ func TestProfile(t *testing.T) { ModuleRoots: []string{"."}, NoGit: true, DefaultProfile: "local", - }, local.ProjectConfig()) + }, profile.ProjectConfig()) - cm := local.ConfigurationManager() + cm := profile.ConfigurationManager() passwordKey := configuration.NewRef("echo", "password") err = cm.Set(ctx, providers.InlineProviderKey, passwordKey, "hello") assert.NoError(t, err)