diff --git a/flyctl/flyctl.go b/flyctl/flyctl.go index 58213eb0da..31b86490d1 100644 --- a/flyctl/flyctl.go +++ b/flyctl/flyctl.go @@ -10,6 +10,7 @@ import ( fly "github.com/superfly/fly-go" "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/internal/instrument" + "github.com/superfly/flyctl/internal/profile" "github.com/superfly/flyctl/terminal" "gopkg.in/yaml.v3" ) @@ -18,9 +19,7 @@ var configDir string // InitConfig - Initialises config file for Viper func InitConfig() { - var dir string - - dir, err := helpers.GetConfigDirectory() + dir, err := configDirectory() if err != nil { fmt.Println("Error accessing home directory", err) @@ -39,6 +38,27 @@ func InitConfig() { initViper() } +// configDirectory resolves the active profile's config directory. +// +// This runs while the root command is being built, before cobra has parsed +// anything, so the profile flag is scraped straight out of os.Args. A profile +// that fails to resolve is not reported here: the same resolution runs again +// as a command preparer, which raises a far better error. Falling back to the +// plain config directory keeps this from being the thing that reports it. +func configDirectory() (string, error) { + opts := profile.ResolveOptions{Flag: profile.FlagFromArgs(os.Args[1:])} + + if wd, err := os.Getwd(); err == nil { + opts.WorkingDir = wd + } + + if res, err := profile.Resolve(opts); err == nil { + return res.Dir, nil + } + + return helpers.GetConfigDirectory() +} + // ConfigDir - Returns Directory holding the Config file func ConfigDir() string { return configDir diff --git a/internal/cmdutil/preparers/preparers.go b/internal/cmdutil/preparers/preparers.go index 8e255ce045..2e74e8552f 100644 --- a/internal/cmdutil/preparers/preparers.go +++ b/internal/cmdutil/preparers/preparers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "os" "path/filepath" "strings" @@ -11,13 +12,14 @@ import ( "github.com/superfly/client-signals/go" fly "github.com/superfly/fly-go" "github.com/superfly/fly-go/flaps" - "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/command_context" "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flag/flagctx" "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/instrument" "github.com/superfly/flyctl/internal/logger" + "github.com/superfly/flyctl/internal/profile" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/internal/uiex" mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1" @@ -118,15 +120,43 @@ func InitClient(ctx context.Context) (context.Context, error) { } func DetermineConfigDir(ctx context.Context) (context.Context, error) { - dir, err := helpers.GetConfigDirectory() + var opts profile.ResolveOptions + + // Flags are not in context on every path into this preparer (shell + // completion sets up its own, thinner context), so read them defensively. + if fs := flagctx.FromContextOrNil(ctx); fs != nil { + if v, err := fs.GetString(profile.FlagName); err == nil { + opts.Flag = v + } + } + + // Resolved independently of state.WorkingDirectory, which the completion + // path never sets. + if wd, err := os.Getwd(); err == nil { + opts.WorkingDir = wd + } + + res, err := profile.Resolve(opts) if err != nil { - return ctx, err + // Most commands must stop here: reaching a different Fly.io account + // than the one asked for is worse than not running at all. The profile + // management commands are the exception, since they are how the user + // repairs a dangling profile reference in the first place. + if !command_context.HasAnnotation(ctx, profile.TolerateUnresolvedAnnotation) { + return ctx, err + } + + if res, err = profile.Fallback(err); err != nil { + return ctx, err + } } logger.FromContext(ctx). - Debugf("determined config directory: %q", dir) + Debugf("determined config directory: %q (profile %q via %s)", res.Dir, res.Name, res.Source) + + ctx = profile.NewContext(ctx, res) - return state.WithConfigDirectory(ctx, dir), nil + return state.WithConfigDirectory(ctx, res.Dir), nil } // ApplyAliases consolidates flags with aliases into a single source-of-truth flag. diff --git a/internal/command/profile/add.go b/internal/command/profile/add.go new file mode 100644 index 0000000000..d49bcd30fb --- /dev/null +++ b/internal/command/profile/add.go @@ -0,0 +1,138 @@ +package profile + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + fly "github.com/superfly/fly-go" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/command/auth/webauth" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/internal/state" + "github.com/superfly/flyctl/iostreams" +) + +func newAdd() *cobra.Command { + const ( + short = "Add a profile and log a Fly.io account into it" + long = `Creates a new, empty config directory for the named profile and +logs an account into it, leaving every other profile untouched. + +By default this opens a browser to log in. Pass --token to store an existing +API token instead, which is what CI and scripted setups want. + +Adding a profile does not switch to it. Pass --use to do both at once. +` + ) + + cmd := command.New("add ", short, long, runAdd) + + cmd.Aliases = []string{"create", "new"} + cmd.Args = cobra.ExactArgs(1) + + flag.Add(cmd, + flag.String{ + Name: "token", + Description: "Store this API token instead of opening a browser to log in", + }, + flag.Bool{ + Name: "use", + Description: "Make the new profile active once it is created", + }, + ) + + return cmd +} + +func runAdd(ctx context.Context) error { + io := iostreams.FromContext(ctx) + cs := io.ColorScheme() + + name := flag.FirstArg(ctx) + if err := profilelib.ValidateName(name); err != nil { + return err + } + + dir, err := profilelib.Create(name) + if err != nil { + return err + } + + // Anything that fails from here leaves a half-built profile behind, which + // would then resolve to an account-less config directory. Clean it up so + // the failure is total rather than partial. + committed := false + defer func() { + if !committed { + _ = profilelib.Remove(name) + } + }() + + // Point the login flow at the new profile's directory rather than the one + // this command resolved to. + loginCtx := state.WithConfigDirectory(ctx, dir) + + token := flag.GetString(ctx, "token") + if token == "" { + if token, err = webauth.RunWebLogin(loginCtx, false); err != nil { + return err + } + } + + if err := webauth.SaveToken(loginCtx, token); err != nil { + return err + } + + // SaveToken already greeted the user by email, but it does not hand the + // address back, so ask once more to cache it for `fly profile list`. + email, err := currentUserEmail(ctx, token) + if err != nil { + return err + } + + now := time.Now() + if err := profilelib.WriteMetadata(name, profilelib.Metadata{ + Email: email, + CreatedAt: now, + VerifiedAt: now, + }); err != nil { + return err + } + + committed = true + + fmt.Fprintf(io.Out, "\ncreated profile %s for %s\n", cs.Bold(name), cs.Bold(email)) + + if flag.GetBool(ctx, "use") { + if err := profilelib.SetActive(name); err != nil { + return err + } + + fmt.Fprintf(io.Out, "switched to profile %s\n", cs.Bold(name)) + + return nil + } + + fmt.Fprintf(io.Out, "\nUse it with any of:\n") + fmt.Fprintf(io.Out, " fly profile use %s # switch globally\n", name) + fmt.Fprintf(io.Out, " fly profile link %s # pin this directory tree to it\n", name) + fmt.Fprintf(io.Out, " fly --profile %s # just this command\n", name) + + return nil +} + +func currentUserEmail(ctx context.Context, token string) (string, error) { + user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{ + AccessToken: token, + }).GetCurrentUser(ctx) + if err != nil { + return "", fmt.Errorf("failed retrieving current user: %w", err) + } + + return user.Email, nil +} diff --git a/internal/command/profile/link.go b/internal/command/profile/link.go new file mode 100644 index 0000000000..a5801c255d --- /dev/null +++ b/internal/command/profile/link.go @@ -0,0 +1,71 @@ +package profile + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/iostreams" +) + +func newLink() *cobra.Command { + const ( + short = "Pin the current directory tree to a profile" + long = `Writes a .fly-profile file in the current directory naming the +profile to use for it and everything beneath it. + +This is what makes multi-account work painless: once a project is linked, any +flyctl command run inside it reaches the right account with no switching and +no flags, no matter which profile is active globally. + +Commit the file to source control to share the binding with the rest of the +team, or add it to .gitignore to keep it personal. +` + ) + + cmd := command.New("link ", short, long, runLink) + + cmd.Aliases = []string{"bind"} + cmd.Args = cobra.ExactArgs(1) + + return cmd +} + +func runLink(ctx context.Context) error { + io := iostreams.FromContext(ctx) + cs := io.ColorScheme() + + name := flag.FirstArg(ctx) + + if err := profilelib.ValidateName(name); err != nil { + return err + } + + exists, err := profilelib.Exists(name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("profile %q does not exist; create it with `fly profile add %s`", name, name) + } + + wd, err := os.Getwd() + if err != nil { + return err + } + + path, err := profilelib.WriteProjectFile(wd, name) + if err != nil { + return err + } + + fmt.Fprintf(io.Out, "wrote %s\n", path) + fmt.Fprintf(io.Out, "flyctl commands under %s will now use profile %s\n", wd, cs.Bold(name)) + + return nil +} diff --git a/internal/command/profile/list.go b/internal/command/profile/list.go new file mode 100644 index 0000000000..93934d6ee7 --- /dev/null +++ b/internal/command/profile/list.go @@ -0,0 +1,182 @@ +package profile + +import ( + "context" + "fmt" + "path/filepath" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + fly "github.com/superfly/fly-go" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" +) + +func newList() *cobra.Command { + const ( + short = "List stored credential profiles" + long = `Lists every stored profile, the account behind it and where its +credentials live. The profile in effect for the current directory is marked +with an asterisk. + +Account names are read from a local cache written at login. Pass --refresh to +re-check every profile against the API, which also reveals expired or revoked +tokens. +` + ) + + cmd := command.New("list", short, long, runList) + + cmd.Aliases = []string{"ls"} + + flag.Add(cmd, + flag.JSONOutput(), + flag.Bool{ + Name: "refresh", + Description: "Verify each profile's token against the API and update the cached account name", + }, + ) + + return cmd +} + +type listEntry struct { + Name string `json:"name"` + Dir string `json:"config_dir"` + Email string `json:"email,omitempty"` + Status string `json:"status"` + Active bool `json:"active"` + LoggedIn bool `json:"logged_in"` + LastLogin string `json:"last_login,omitempty"` +} + +func runList(ctx context.Context) error { + io := iostreams.FromContext(ctx) + + profiles, err := profilelib.List() + if err != nil { + return err + } + + // Mark whichever profile this very command resolved to, so the asterisk + // reflects the working directory rather than just the `use` pointer. + var inEffect string + if res, ok := profilelib.FromContext(ctx); ok && res.Err == nil { + inEffect = res.Name + } + + refresh := flag.GetBool(ctx, "refresh") + + entries := make([]listEntry, 0, len(profiles)) + for _, p := range profiles { + entry := listEntry{ + Name: p.Name, + Dir: p.Dir, + Email: p.Metadata.Email, + Active: p.Name == inEffect, + } + + cfg, err := config.Load(ctx, filepath.Join(p.Dir, config.FileName)) + switch { + case err != nil: + entry.Status = "unreadable" + case cfg.Tokens == nil || cfg.Tokens.GraphQL() == "": + entry.Status = "logged out" + default: + entry.LoggedIn = true + entry.Status = "ok" + + if !cfg.LastLogin.IsZero() { + entry.LastLogin = cfg.LastLogin.Format(time.RFC3339) + } + + if refresh { + entry.Email, entry.Status = verify(ctx, cfg.Tokens.GraphQL()) + + // Keep the cache honest, so the next plain `list` agrees. + if entry.Status == "ok" { + md := p.Metadata + md.Email = entry.Email + md.VerifiedAt = time.Now() + _ = profilelib.WriteMetadata(p.Name, md) + } + } + } + + entries = append(entries, entry) + } + + if config.FromContext(ctx).JSONOutput { + return render.JSON(io.Out, entries) + } + + cs := io.ColorScheme() + + w := tabwriter.NewWriter(io.Out, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "\tNAME\tACCOUNT\tSTATUS\tCONFIG DIRECTORY") + + for _, e := range entries { + marker := " " + name := e.Name + if e.Active { + marker = "*" + name = cs.Bold(name) + } + + email := e.Email + if email == "" { + email = "-" + } + + status := e.Status + switch status { + case "ok": + status = cs.Green(status) + case "logged out": + status = cs.Yellow(status) + default: + status = cs.Red(status) + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", marker, name, email, status, e.Dir) + } + + if err := w.Flush(); err != nil { + return err + } + + if len(entries) == 1 { + fmt.Fprintf(io.Out, "\nOnly the default profile exists. Add another account with `fly profile add `.\n") + } + + // Nothing is marked in effect when resolution failed, which would leave a + // confusing listing with no asterisk and no explanation. + if res, ok := profilelib.FromContext(ctx); ok && res.Err != nil { + fmt.Fprintf(io.ErrOut, "\n%s %s\n", cs.Yellow("warning:"), res.Err) + } + + return nil +} + +// verify trades a token for the account it belongs to, which doubles as a +// liveness check on the credentials. +func verify(ctx context.Context, token string) (email, status string) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{ + AccessToken: token, + }).GetCurrentUser(ctx) + if err != nil { + return "", "invalid token" + } + + return user.Email, "ok" +} diff --git a/internal/command/profile/profile.go b/internal/command/profile/profile.go new file mode 100644 index 0000000000..834f7af086 --- /dev/null +++ b/internal/command/profile/profile.go @@ -0,0 +1,67 @@ +// Package profile implements the profile command chain, which manages named, +// isolated credential profiles so one machine can drive several Fly.io +// accounts without logging in and out. +package profile + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + profilelib "github.com/superfly/flyctl/internal/profile" +) + +// New initializes and returns a new profile Command. +func New() *cobra.Command { + const short = "Manage credential profiles for multiple Fly.io accounts" + + long := heredoc.Doc(` + Profiles let a single machine hold credentials for several Fly.io + accounts at once and pick between them per shell, per project or per + command. + + Each profile is a complete, isolated flyctl config directory, so it + carries its own access token, WireGuard peer state and agent. The + "default" profile is the existing ~/.fly directory, which means an + installation that has never used profiles keeps working unchanged. + + The profile in effect is resolved in this order: + + 1. FLY_CONFIG_DIR, which pins a config directory outright + 2. the --profile flag + 3. the FLY_PROFILE environment variable + 4. the nearest .fly-profile file at or above the current directory + 5. the profile selected by "fly profile use" + 6. the "default" profile + + A profile named by any of those that does not exist is an error, never + a silent fallback to another account. + `) + + cmd := command.New("profile", short, long, nil) + + cmd.Aliases = []string{"profiles"} + + cmd.AddCommand( + newList(), + newAdd(), + newUse(), + newShow(), + newLink(), + newRemove(), + newRename(), + ) + + // A .fly-profile file or an active pointer left naming a deleted profile + // makes every other command refuse to run. These are the commands that + // repair that, so they must survive it. + for _, sub := range cmd.Commands() { + if sub.Annotations == nil { + sub.Annotations = map[string]string{} + } + + sub.Annotations[profilelib.TolerateUnresolvedAnnotation] = "1" + } + + return cmd +} diff --git a/internal/command/profile/remove.go b/internal/command/profile/remove.go new file mode 100644 index 0000000000..c53436e1e9 --- /dev/null +++ b/internal/command/profile/remove.go @@ -0,0 +1,84 @@ +package profile + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/iostreams" +) + +func newRemove() *cobra.Command { + const ( + short = "Delete a profile and its stored credentials" + long = `Deletes the profile's config directory, including its access +token, WireGuard peer state and cached account details. + +This only removes local credentials. It does not touch the Fly.io account, its +organizations or its apps. If the profile was active, the active profile falls +back to "default". +` + ) + + cmd := command.New("remove ", short, long, runRemove) + + cmd.Aliases = []string{"rm", "delete"} + cmd.Args = cobra.ExactArgs(1) + + flag.Add(cmd, flag.Yes()) + + return cmd +} + +func runRemove(ctx context.Context) error { + io := iostreams.FromContext(ctx) + cs := io.ColorScheme() + + name := flag.FirstArg(ctx) + + if err := profilelib.ValidateName(name); err != nil { + return err + } + + exists, err := profilelib.Exists(name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("profile %q does not exist", name) + } + + dir, err := profilelib.Dir(name) + if err != nil { + return err + } + + if !flag.GetYes(ctx) { + md, _ := profilelib.ReadMetadata(name) + + msg := fmt.Sprintf("Delete profile %q and its credentials in %s?", name, dir) + if md.Email != "" { + msg = fmt.Sprintf("Delete profile %q (%s) and its credentials in %s?", name, md.Email, dir) + } + + switch confirmed, err := prompt.Confirm(ctx, msg); { + case err != nil: + return err + case !confirmed: + return nil + } + } + + if err := profilelib.Remove(name); err != nil { + return err + } + + fmt.Fprintf(io.Out, "removed profile %s\n", cs.Bold(name)) + + return nil +} diff --git a/internal/command/profile/rename.go b/internal/command/profile/rename.go new file mode 100644 index 0000000000..095e17247b --- /dev/null +++ b/internal/command/profile/rename.go @@ -0,0 +1,51 @@ +package profile + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/iostreams" +) + +func newRename() *cobra.Command { + const ( + short = "Rename a profile" + long = `Renames a stored profile, carrying the active selection across +if it pointed at the renamed profile. + +Any .fly-profile file naming the old profile still names the old profile, so +re-link those projects afterwards. +` + ) + + cmd := command.New("rename ", short, long, runRename) + + cmd.Args = cobra.ExactArgs(2) + + return cmd +} + +func runRename(ctx context.Context) error { + io := iostreams.FromContext(ctx) + cs := io.ColorScheme() + + args := flag.Args(ctx) + oldName, newName := args[0], args[1] + + if err := profilelib.ValidateName(newName); err != nil { + return err + } + + if err := profilelib.Rename(oldName, newName); err != nil { + return err + } + + fmt.Fprintf(io.Out, "renamed profile %s to %s\n", cs.Bold(oldName), cs.Bold(newName)) + + return nil +} diff --git a/internal/command/profile/show.go b/internal/command/profile/show.go new file mode 100644 index 0000000000..22334fee55 --- /dev/null +++ b/internal/command/profile/show.go @@ -0,0 +1,108 @@ +package profile + +import ( + "context" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" +) + +func newShow() *cobra.Command { + const ( + short = "Show which profile is in effect, and why" + long = `Reports the profile the current directory and environment +resolve to, the config directory backing it, and which rule selected it. Use +this to confirm which account a command is about to touch. +` + ) + + cmd := command.New("show", short, long, runShow) + + cmd.Aliases = []string{"which", "current"} + + flag.Add(cmd, flag.JSONOutput()) + + return cmd +} + +func runShow(ctx context.Context) error { + io := iostreams.FromContext(ctx) + + // The resolution that selected this command's own config directory is the + // answer, so report it rather than resolving a second time. + res, ok := profilelib.FromContext(ctx) + if !ok { + return fmt.Errorf("profile was not resolved for this command") + } + + cs := io.ColorScheme() + + name := res.Name + if name == "" { + name = "(none)" + } + + selectedBy := string(res.Source) + if res.Detail != "" { + selectedBy = fmt.Sprintf("%s (%s)", res.Source, res.Detail) + } + + var md profilelib.Metadata + if res.Name != "" { + md, _ = profilelib.ReadMetadata(res.Name) + } + + // Resolution only fails this far in on the tolerated path, where the + // answer is not "which profile" but "why is there no usable one". + if res.Err != nil { + if config.FromContext(ctx).JSONOutput { + return render.JSON(io.Out, map[string]string{ + "profile": "", + "error": res.Err.Error(), + }) + } + + fmt.Fprintf(io.Out, "%s %s\n", cs.Red("unresolved:"), res.Err) + fmt.Fprintf(io.Out, "\nFix it by creating the profile, or by pointing at one that exists:\n") + fmt.Fprintf(io.Out, " fly profile list\n") + fmt.Fprintf(io.Out, " fly profile use \n") + fmt.Fprintf(io.Out, " fly profile link # rewrites %s here\n", profilelib.ProjectFileName) + + return nil + } + + if config.FromContext(ctx).JSONOutput { + return render.JSON(io.Out, map[string]string{ + "profile": name, + "config_dir": res.Dir, + "selected_by": selectedBy, + "email": md.Email, + }) + } + + w := tabwriter.NewWriter(io.Out, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Profile\t%s\n", cs.Bold(name)) + fmt.Fprintf(w, "Config directory\t%s\n", res.Dir) + fmt.Fprintf(w, "Selected by\t%s\n", selectedBy) + + if md.Email != "" { + fmt.Fprintf(w, "Account\t%s\n", md.Email) + } + + // A pinned config dir means profiles are out of the picture entirely; + // saying so avoids a confusing "(none)" with no explanation. + if res.Source == profilelib.SourceConfigDirEnv { + fmt.Fprintf(w, "Note\t%s is set, so profile selection is bypassed\n", profilelib.ConfigDirEnvKey) + } + + return w.Flush() +} diff --git a/internal/command/profile/use.go b/internal/command/profile/use.go new file mode 100644 index 0000000000..1f35ca670c --- /dev/null +++ b/internal/command/profile/use.go @@ -0,0 +1,90 @@ +package profile + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + profilelib "github.com/superfly/flyctl/internal/profile" + "github.com/superfly/flyctl/iostreams" +) + +func newUse() *cobra.Command { + const ( + short = "Switch the active profile" + long = `Selects the profile every subsequent flyctl command uses, unless +something more specific overrides it: the --profile flag, the FLY_PROFILE +environment variable and a .fly-profile file all win over this setting. + +Switching to "default" restores the original ~/.fly credentials. +` + ) + + cmd := command.New("use ", short, long, runUse) + + cmd.Aliases = []string{"switch"} + cmd.Args = cobra.ExactArgs(1) + + return cmd +} + +func runUse(ctx context.Context) error { + io := iostreams.FromContext(ctx) + cs := io.ColorScheme() + + name := flag.FirstArg(ctx) + + if err := profilelib.ValidateName(name); err != nil { + return err + } + + exists, err := profilelib.Exists(name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("profile %q does not exist; create it with `fly profile add %s`", name, name) + } + + if err := profilelib.SetActive(name); err != nil { + return err + } + + md, _ := profilelib.ReadMetadata(name) + if md.Email != "" { + fmt.Fprintf(io.Out, "switched to profile %s (%s)\n", cs.Bold(name), md.Email) + } else { + fmt.Fprintf(io.Out, "switched to profile %s\n", cs.Bold(name)) + } + + // A narrower rule silently outranking the switch the user just made is + // exactly the surprise this tool exists to prevent, so call it out. + res, ok := profilelib.FromContext(ctx) + switch { + case !ok: + case res.Err != nil: + // Switching the active profile does not clear a dangling .fly-profile + // or FLY_PROFILE, so the next command here would still fail. + fmt.Fprintf(io.ErrOut, "%s %s\n", cs.Yellow("warning:"), res.Err) + case res.Name != name: + switch res.Source { + case profilelib.SourceDefault, profilelib.SourceActive: + // The old active pointer; the switch has already replaced it. + default: + selectedBy := string(res.Source) + if res.Detail != "" { + selectedBy = fmt.Sprintf("%s (%s)", res.Source, res.Detail) + } + + fmt.Fprintf(io.ErrOut, + "%s here, %s still takes precedence and selects profile %q\n", + cs.Yellow("warning:"), selectedBy, res.Name, + ) + } + } + + return nil +} diff --git a/internal/command/root/root.go b/internal/command/root/root.go index 38b59b8a40..6fdcc4d4da 100644 --- a/internal/command/root/root.go +++ b/internal/command/root/root.go @@ -50,6 +50,7 @@ import ( "github.com/superfly/flyctl/internal/command/ping" "github.com/superfly/flyctl/internal/command/platform" "github.com/superfly/flyctl/internal/command/postgres" + "github.com/superfly/flyctl/internal/command/profile" "github.com/superfly/flyctl/internal/command/proxy" "github.com/superfly/flyctl/internal/command/redis" "github.com/superfly/flyctl/internal/command/regions" @@ -71,6 +72,7 @@ import ( "github.com/superfly/flyctl/internal/command/wireguard" "github.com/superfly/flyctl/internal/flag/flagnames" "github.com/superfly/flyctl/internal/flyutil" + profilelib "github.com/superfly/flyctl/internal/profile" ) // New initializes and returns a reference to a new root command. @@ -100,6 +102,7 @@ func New() *cobra.Command { _ = fs.StringP(flagnames.AccessToken, "t", "", "Fly API Access Token") _ = fs.BoolP(flagnames.Verbose, "", false, "Verbose output") _ = fs.BoolP(flagnames.Debug, "", false, "Print additional logs and traces") + _ = fs.String(profilelib.FlagName, "", "Credential profile to run this command against") flyctl.InitConfig() @@ -109,6 +112,7 @@ func New() *cobra.Command { version.New(), group(orgs.New(), "acl"), group(auth.New(), "acl"), + group(profile.New(), "acl"), group(platform.New(), "more_help"), group(docs.New(), "more_help"), group(releases.New(), "upkeep"), diff --git a/internal/command_context/context.go b/internal/command_context/context.go index 0137be0dcb..1f08611b24 100644 --- a/internal/command_context/context.go +++ b/internal/command_context/context.go @@ -18,3 +18,24 @@ func NewContext(ctx context.Context, cmd *cobra.Command) context.Context { func FromContext(ctx context.Context) *cobra.Command { return ctx.Value(contextKey{}).(*cobra.Command) } + +// FromContextOrNil returns the Command ctx carries, or nil in case ctx carries +// none. It exists for command preparers, which also run on paths that never +// set up a command, such as shell completion. +func FromContextOrNil(ctx context.Context) *cobra.Command { + cmd, _ := ctx.Value(contextKey{}).(*cobra.Command) + + return cmd +} + +// HasAnnotation reports whether the Command ctx carries is annotated with key. +func HasAnnotation(ctx context.Context, key string) bool { + cmd := FromContextOrNil(ctx) + if cmd == nil { + return false + } + + _, ok := cmd.Annotations[key] + + return ok +} diff --git a/internal/flag/flagctx/helpers.go b/internal/flag/flagctx/helpers.go index 2338271ed4..a6d1bb54b8 100644 --- a/internal/flag/flagctx/helpers.go +++ b/internal/flag/flagctx/helpers.go @@ -21,3 +21,12 @@ func NewContext(ctx context.Context, fs *pflag.FlagSet) context.Context { func FromContext(ctx context.Context) *pflag.FlagSet { return ctx.Value(contextKey{}).(*pflag.FlagSet) } + +// FromContextOrNil returns the FlagSet ctx carries, or nil in case ctx carries +// none. It exists for callers that run before flags are guaranteed to be in +// context, such as the preparer that determines the config directory. +func FromContextOrNil(ctx context.Context) *pflag.FlagSet { + fs, _ := ctx.Value(contextKey{}).(*pflag.FlagSet) + + return fs +} diff --git a/internal/profile/context.go b/internal/profile/context.go new file mode 100644 index 0000000000..6cd5fba9bf --- /dev/null +++ b/internal/profile/context.go @@ -0,0 +1,18 @@ +package profile + +import "context" + +type contextKey struct{} + +// NewContext derives a context that carries res from ctx. +func NewContext(ctx context.Context, res Resolution) context.Context { + return context.WithValue(ctx, contextKey{}, res) +} + +// FromContext returns the Resolution ctx carries, along with whether ctx +// carried one at all. +func FromContext(ctx context.Context) (Resolution, bool) { + res, ok := ctx.Value(contextKey{}).(Resolution) + + return res, ok +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000000..eb9b8cfb91 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,610 @@ +// Package profile implements named, fully isolated flyctl credential +// profiles, so a single machine can drive multiple Fly.io accounts without +// logging in and out. +// +// A profile is nothing more than a flyctl config directory. The default +// profile is the legacy `~/.fly` directory itself, which keeps an existing +// installation working untouched; named profiles live beside it under +// `~/.fly/profiles/`. Because a profile is a whole config directory and +// not just a token, each one carries its own access token, metrics token, +// WireGuard peer state and agent socket. +package profile + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + // Default denotes the name of the profile backed by the legacy config + // directory. + Default = "default" + + // FlagName denotes the name of the global profile flag. + FlagName = "profile" + + // EnvKey denotes the environment variable that selects a profile by name. + EnvKey = "FLY_PROFILE" + + // HomeEnvKey denotes the environment variable that overrides where + // profiles are stored. + HomeEnvKey = "FLY_PROFILE_HOME" + + // ConfigDirEnvKey denotes the environment variable that pins the config + // directory outright, bypassing profile resolution. + ConfigDirEnvKey = "FLY_CONFIG_DIR" + + // ProjectFileName denotes the name of the file that binds a directory + // tree to a profile. + ProjectFileName = ".fly-profile" + + // MetadataFileName denotes the name of the file holding a profile's + // router-managed metadata. + MetadataFileName = "profile.yml" + + profilesDirName = "profiles" + activeFileName = "active_profile" + + dirPerm = 0o700 + filePerm = 0o600 +) + +// Source describes how a profile came to be selected. +type Source string + +const ( + SourceConfigDirEnv Source = "FLY_CONFIG_DIR" + SourceFlag Source = "--profile" + SourceEnv Source = "FLY_PROFILE" + SourceProjectFile Source = ".fly-profile" + SourceActive Source = "active profile" + SourceDefault Source = "default" +) + +// Resolution is the outcome of resolving which config directory to use. +type Resolution struct { + // Name is the resolved profile name. It is empty when ConfigDirEnvKey + // pinned the directory, since that bypasses profiles entirely. + Name string + + // Dir is the config directory flyctl should read and write. + Dir string + + // Source records which rule selected the profile. + Source Source + + // Detail carries extra context about the source, such as the path of the + // .fly-profile file that matched. It may be empty. + Detail string + + // Err records why resolution failed, on the tolerated paths that fall back + // to the default profile rather than refusing to run. It is nil whenever + // the profile was selected normally. + Err error +} + +// Metadata is the router-managed bookkeeping stored alongside a profile's +// flyctl config. None of it is authoritative; it exists so `fly profile list` +// can name the account behind a profile without a round trip per profile. +type Metadata struct { + Email string `yaml:"email,omitempty"` + CreatedAt time.Time `yaml:"created_at,omitempty"` + VerifiedAt time.Time `yaml:"verified_at,omitempty"` +} + +// Profile describes a single stored profile. +type Profile struct { + Name string + Dir string + Metadata Metadata +} + +// ErrNotExist is returned when a named profile has no directory on disk. +var ErrNotExist = errors.New("profile does not exist") + +// nameRE bounds profile names to what is safe as a single path component. +// Names become directory names, so anything that could escape the profile +// store is rejected outright. +var nameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +// ValidateName reports whether name is usable as a profile name. +func ValidateName(name string) error { + switch { + case name == "": + return errors.New("profile name is empty") + case name == "." || name == "..": + return fmt.Errorf("%q is not a valid profile name", name) + case !nameRE.MatchString(name): + return fmt.Errorf( + "%q is not a valid profile name: use 1-64 characters of letters, digits, dot, dash or underscore, starting with a letter or digit", + name, + ) + default: + return nil + } +} + +// Home returns the directory the profile store lives in. It is the legacy +// flyctl config directory unless HomeEnvKey overrides it. +// +// Home deliberately ignores ConfigDirEnvKey: that variable pins a single +// config directory, and the profile store must stay put regardless. +func Home() (string, error) { + if v := strings.TrimSpace(os.Getenv(HomeEnvKey)); v != "" { + return v, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed determining home directory: %w", err) + } + + return filepath.Join(home, ".fly"), nil +} + +// Dir returns the config directory backing the named profile. It does not +// check whether that directory exists. +func Dir(name string) (string, error) { + if err := ValidateName(name); err != nil { + return "", err + } + + home, err := Home() + if err != nil { + return "", err + } + + if name == Default { + return home, nil + } + + return filepath.Join(home, profilesDirName, name), nil +} + +// Exists reports whether the named profile is present on disk. The default +// profile always exists, since it is the config directory itself. +func Exists(name string) (bool, error) { + if name == Default { + return true, nil + } + + dir, err := Dir(name) + if err != nil { + return false, err + } + + switch fi, err := os.Stat(dir); { + case errors.Is(err, os.ErrNotExist): + return false, nil + case err != nil: + return false, err + default: + return fi.IsDir(), nil + } +} + +// List returns every stored profile, default first and the rest sorted by +// name. +func List() ([]Profile, error) { + home, err := Home() + if err != nil { + return nil, err + } + + out := []Profile{{Name: Default, Dir: home, Metadata: readMetadata(home)}} + + entries, err := os.ReadDir(filepath.Join(home, profilesDirName)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + var named []Profile + for _, entry := range entries { + if !entry.IsDir() || ValidateName(entry.Name()) != nil { + continue + } + + dir := filepath.Join(home, profilesDirName, entry.Name()) + named = append(named, Profile{ + Name: entry.Name(), + Dir: dir, + Metadata: readMetadata(dir), + }) + } + + sort.Slice(named, func(i, j int) bool { return named[i].Name < named[j].Name }) + + return append(out, named...), nil +} + +// Create makes the directory backing the named profile and returns its path. +// It reports an error if the profile already exists. +func Create(name string) (string, error) { + if name == Default { + return "", fmt.Errorf("the %q profile always exists and cannot be created", Default) + } + + exists, err := Exists(name) + if err != nil { + return "", err + } + if exists { + return "", fmt.Errorf("profile %q already exists", name) + } + + dir, err := Dir(name) + if err != nil { + return "", err + } + + if err := os.MkdirAll(dir, dirPerm); err != nil { + return "", fmt.Errorf("failed creating profile directory: %w", err) + } + + return dir, nil +} + +// Remove deletes the named profile and everything in it. If the profile was +// active, the active pointer falls back to the default profile. +func Remove(name string) error { + if name == Default { + return fmt.Errorf("the %q profile cannot be removed; use `fly auth logout` to clear its credentials", Default) + } + + exists, err := Exists(name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%w: %s", ErrNotExist, name) + } + + dir, err := Dir(name) + if err != nil { + return err + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("failed removing profile directory: %w", err) + } + + // Leaving the active pointer dangling would break every subsequent + // command, so retire it along with the profile. + if active, err := Active(); err == nil && active == name { + return SetActive(Default) + } + + return nil +} + +// Rename moves the profile stored under oldName to newName, carrying the +// active pointer across if it referred to the renamed profile. +func Rename(oldName, newName string) error { + if oldName == Default || newName == Default { + return fmt.Errorf("the %q profile cannot be renamed", Default) + } + + exists, err := Exists(oldName) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%w: %s", ErrNotExist, oldName) + } + + if exists, err := Exists(newName); err != nil { + return err + } else if exists { + return fmt.Errorf("profile %q already exists", newName) + } + + oldDir, err := Dir(oldName) + if err != nil { + return err + } + + newDir, err := Dir(newName) + if err != nil { + return err + } + + if err := os.Rename(oldDir, newDir); err != nil { + return fmt.Errorf("failed renaming profile directory: %w", err) + } + + if active, err := Active(); err == nil && active == oldName { + return SetActive(newName) + } + + return nil +} + +// Active returns the name of the profile selected by `fly profile use`, or +// Default when none has been selected. +func Active() (string, error) { + home, err := Home() + if err != nil { + return "", err + } + + b, err := os.ReadFile(filepath.Join(home, activeFileName)) + switch { + case errors.Is(err, os.ErrNotExist): + return Default, nil + case err != nil: + return "", err + } + + name := strings.TrimSpace(string(b)) + if name == "" { + return Default, nil + } + + return name, nil +} + +// SetActive records name as the active profile. Selecting the default profile +// clears the pointer rather than writing it, so an untouched installation +// leaves no trace. +func SetActive(name string) error { + if err := ValidateName(name); err != nil { + return err + } + + home, err := Home() + if err != nil { + return err + } + + path := filepath.Join(home, activeFileName) + + if name == Default { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil + } + + if err := os.MkdirAll(home, dirPerm); err != nil { + return err + } + + return os.WriteFile(path, []byte(name+"\n"), filePerm) +} + +// FindProjectFile walks up from dir looking for a ProjectFileName. It returns +// the path of the file and the profile name it names. Both are empty when no +// such file is found. +func FindProjectFile(dir string) (path, name string, err error) { + if dir == "" { + return "", "", nil + } + + dir, err = filepath.Abs(dir) + if err != nil { + return "", "", err + } + + for { + candidate := filepath.Join(dir, ProjectFileName) + + b, err := os.ReadFile(candidate) + switch { + case err == nil: + name := strings.TrimSpace(string(b)) + if name == "" { + return "", "", fmt.Errorf("%s is empty", candidate) + } + + // Tolerate a trailing comment line so the file can explain itself. + name, _, _ = strings.Cut(name, "\n") + + return candidate, strings.TrimSpace(name), nil + case !errors.Is(err, os.ErrNotExist): + return "", "", err + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", "", nil + } + dir = parent + } +} + +// WriteProjectFile binds dir to the named profile. +func WriteProjectFile(dir, name string) (string, error) { + if err := ValidateName(name); err != nil { + return "", err + } + + path := filepath.Join(dir, ProjectFileName) + if err := os.WriteFile(path, []byte(name+"\n"), 0o644); err != nil { + return "", err + } + + return path, nil +} + +// ResolveOptions carries the inputs to Resolve that cannot be read from the +// environment. +type ResolveOptions struct { + // Flag is the value of the global --profile flag, empty when unset. + Flag string + + // WorkingDir is where the search for a .fly-profile file starts. An empty + // value skips that step. + WorkingDir string +} + +// Resolve decides which config directory flyctl should use, in descending +// order of precedence: +// +// 1. FLY_CONFIG_DIR, which pins a directory and bypasses profiles entirely +// 2. the --profile flag +// 3. the FLY_PROFILE environment variable +// 4. the nearest .fly-profile file at or above the working directory +// 5. the profile selected by `fly profile use` +// 6. the default profile +// +// A named profile that does not exist is an error rather than a silent +// fallback: quietly deploying to the wrong account is far worse than failing. +func Resolve(opts ResolveOptions) (Resolution, error) { + if v := strings.TrimSpace(os.Getenv(ConfigDirEnvKey)); v != "" { + return Resolution{Dir: v, Source: SourceConfigDirEnv}, nil + } + + if name := strings.TrimSpace(opts.Flag); name != "" { + return resolveNamed(name, SourceFlag, "") + } + + if name := strings.TrimSpace(os.Getenv(EnvKey)); name != "" { + return resolveNamed(name, SourceEnv, "") + } + + path, name, err := FindProjectFile(opts.WorkingDir) + if err != nil { + return Resolution{}, err + } + if name != "" { + return resolveNamed(name, SourceProjectFile, path) + } + + active, err := Active() + if err != nil { + return Resolution{}, err + } + if active != Default { + return resolveNamed(active, SourceActive, "") + } + + dir, err := Dir(Default) + if err != nil { + return Resolution{}, err + } + + return Resolution{Name: Default, Dir: dir, Source: SourceDefault}, nil +} + +// TolerateUnresolvedAnnotation marks commands that must keep working when +// resolution fails. +// +// A .fly-profile file or an active pointer naming a deleted profile would +// otherwise lock the user out of the very commands that repair it, so the +// profile management commands fall back to the default profile and report the +// problem instead of refusing to run. +const TolerateUnresolvedAnnotation = "profile/tolerate-unresolved" + +// Fallback returns a Resolution pointing at the default profile and carrying +// the error that prevented proper resolution. +func Fallback(cause error) (Resolution, error) { + dir, err := Dir(Default) + if err != nil { + return Resolution{}, err + } + + return Resolution{Name: Default, Dir: dir, Source: SourceDefault, Err: cause}, nil +} + +// FlagFromArgs scrapes the value of the global profile flag out of a raw +// argument list. +// +// It exists because some config-directory consumers are initialized while the +// root command is being built, which is before cobra has parsed anything. Both +// `--profile name` and `--profile=name` are recognized. +func FlagFromArgs(args []string) string { + const long = "--" + FlagName + + for i, arg := range args { + switch { + case arg == long: + if i+1 < len(args) { + return strings.TrimSpace(args[i+1]) + } + case strings.HasPrefix(arg, long+"="): + return strings.TrimSpace(strings.TrimPrefix(arg, long+"=")) + } + } + + return "" +} + +func resolveNamed(name string, source Source, detail string) (Resolution, error) { + where := string(source) + if detail != "" { + where = detail + } + + if err := ValidateName(name); err != nil { + return Resolution{}, fmt.Errorf("profile selected by %s is invalid: %w", where, err) + } + + exists, err := Exists(name) + if err != nil { + return Resolution{}, err + } + if !exists { + return Resolution{}, fmt.Errorf( + "profile %q (selected by %s) does not exist; create it with `fly profile add %s`", + name, where, name, + ) + } + + dir, err := Dir(name) + if err != nil { + return Resolution{}, err + } + + return Resolution{Name: name, Dir: dir, Source: source, Detail: detail}, nil +} + +// ReadMetadata returns the router metadata stored for the named profile. +func ReadMetadata(name string) (Metadata, error) { + dir, err := Dir(name) + if err != nil { + return Metadata{}, err + } + + return readMetadata(dir), nil +} + +// WriteMetadata stores router metadata for the named profile. +func WriteMetadata(name string, md Metadata) error { + dir, err := Dir(name) + if err != nil { + return err + } + + b, err := yaml.Marshal(md) + if err != nil { + return err + } + + if err := os.MkdirAll(dir, dirPerm); err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, MetadataFileName), b, filePerm) +} + +// readMetadata is best-effort: metadata is a display convenience, so a +// missing or corrupt file yields a zero value rather than an error. +func readMetadata(dir string) (md Metadata) { + b, err := os.ReadFile(filepath.Join(dir, MetadataFileName)) + if err != nil { + return + } + + _ = yaml.Unmarshal(b, &md) + + return +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000000..47449b7555 --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,363 @@ +package profile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setup points the profile store at a scratch directory and clears every +// environment variable that participates in resolution, so a test only sees +// the inputs it sets itself. +func setup(t *testing.T) string { + t.Helper() + + home := t.TempDir() + + t.Setenv(HomeEnvKey, home) + t.Setenv(ConfigDirEnvKey, "") + t.Setenv(EnvKey, "") + + // Setenv to "" still leaves the variable set, which Resolve treats as + // unset only because it trims and checks for empty. Unset them outright so + // the test exercises the real code path. + require.NoError(t, os.Unsetenv(ConfigDirEnvKey)) + require.NoError(t, os.Unsetenv(EnvKey)) + + return home +} + +func mustCreate(t *testing.T, name string) string { + t.Helper() + + dir, err := Create(name) + require.NoError(t, err) + + return dir +} + +func TestValidateName(t *testing.T) { + valid := []string{"work", "client-a", "acme_prod", "a", "a.b", "A1"} + for _, name := range valid { + assert.NoError(t, ValidateName(name), "expected %q to be valid", name) + } + + // Names become path components, so anything that could escape the store or + // collide with the store's own files must be refused. + invalid := []string{"", ".", "..", "../escape", "a/b", "a\\b", "-leading", ".hidden", "with space"} + for _, name := range invalid { + assert.Error(t, ValidateName(name), "expected %q to be invalid", name) + } +} + +func TestDir(t *testing.T) { + home := setup(t) + + dir, err := Dir(Default) + require.NoError(t, err) + assert.Equal(t, home, dir, "the default profile is the config directory itself") + + dir, err = Dir("work") + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, "profiles", "work"), dir) + + _, err = Dir("../escape") + assert.Error(t, err) +} + +func TestResolveDefaultsToDefaultProfile(t *testing.T) { + home := setup(t) + + res, err := Resolve(ResolveOptions{}) + require.NoError(t, err) + + assert.Equal(t, Default, res.Name) + assert.Equal(t, home, res.Dir) + assert.Equal(t, SourceDefault, res.Source) +} + +func TestResolveConfigDirEnvWins(t *testing.T) { + setup(t) + mustCreate(t, "work") + require.NoError(t, SetActive("work")) + + pinned := t.TempDir() + t.Setenv(ConfigDirEnvKey, pinned) + t.Setenv(EnvKey, "work") + + res, err := Resolve(ResolveOptions{Flag: "work"}) + require.NoError(t, err) + + assert.Equal(t, pinned, res.Dir) + assert.Equal(t, SourceConfigDirEnv, res.Source) + assert.Empty(t, res.Name, "pinning a directory bypasses profiles entirely") +} + +func TestResolvePrecedence(t *testing.T) { + home := setup(t) + + for _, name := range []string{"flagged", "envd", "linked", "active"} { + mustCreate(t, name) + } + require.NoError(t, SetActive("active")) + + project := t.TempDir() + _, err := WriteProjectFile(project, "linked") + require.NoError(t, err) + + t.Run("flag beats everything else", func(t *testing.T) { + t.Setenv(EnvKey, "envd") + + res, err := Resolve(ResolveOptions{Flag: "flagged", WorkingDir: project}) + require.NoError(t, err) + + assert.Equal(t, "flagged", res.Name) + assert.Equal(t, SourceFlag, res.Source) + }) + + t.Run("env beats the project file", func(t *testing.T) { + t.Setenv(EnvKey, "envd") + + res, err := Resolve(ResolveOptions{WorkingDir: project}) + require.NoError(t, err) + + assert.Equal(t, "envd", res.Name) + assert.Equal(t, SourceEnv, res.Source) + }) + + t.Run("project file beats the active profile", func(t *testing.T) { + res, err := Resolve(ResolveOptions{WorkingDir: project}) + require.NoError(t, err) + + assert.Equal(t, "linked", res.Name) + assert.Equal(t, SourceProjectFile, res.Source) + assert.Equal(t, filepath.Join(project, ProjectFileName), res.Detail) + }) + + t.Run("active profile is the fallback", func(t *testing.T) { + res, err := Resolve(ResolveOptions{WorkingDir: t.TempDir()}) + require.NoError(t, err) + + assert.Equal(t, "active", res.Name) + assert.Equal(t, SourceActive, res.Source) + assert.Equal(t, filepath.Join(home, "profiles", "active"), res.Dir) + }) +} + +// A profile that has been deleted, or was never created, must stop the command +// rather than quietly falling back: silently reaching a different Fly.io +// account is the failure this whole feature exists to prevent. +func TestResolveMissingProfileIsAnError(t *testing.T) { + setup(t) + + _, err := Resolve(ResolveOptions{Flag: "ghost"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + assert.Contains(t, err.Error(), "fly profile add ghost") + + t.Setenv(EnvKey, "ghost") + _, err = Resolve(ResolveOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), string(SourceEnv)) +} + +func TestResolveNamesTheProjectFileInErrors(t *testing.T) { + setup(t) + + project := t.TempDir() + _, err := WriteProjectFile(project, "ghost") + require.NoError(t, err) + + _, err = Resolve(ResolveOptions{WorkingDir: project}) + require.Error(t, err) + assert.Contains(t, err.Error(), filepath.Join(project, ProjectFileName)) +} + +func TestFindProjectFileWalksUp(t *testing.T) { + setup(t) + + root := t.TempDir() + nested := filepath.Join(root, "a", "b", "c") + require.NoError(t, os.MkdirAll(nested, 0o700)) + + path, err := WriteProjectFile(root, "work") + require.NoError(t, err) + + foundPath, name, err := FindProjectFile(nested) + require.NoError(t, err) + assert.Equal(t, path, foundPath) + assert.Equal(t, "work", name) + + // The nearest file wins, so a deeper binding overrides a shallower one. + deeper, err := WriteProjectFile(filepath.Join(root, "a"), "other") + require.NoError(t, err) + + foundPath, name, err = FindProjectFile(nested) + require.NoError(t, err) + assert.Equal(t, deeper, foundPath) + assert.Equal(t, "other", name) +} + +func TestFindProjectFileIgnoresTrailingContent(t *testing.T) { + setup(t) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ProjectFileName), + []byte("work\n# the billing account for this client\n"), + 0o644, + )) + + _, name, err := FindProjectFile(dir) + require.NoError(t, err) + assert.Equal(t, "work", name) +} + +func TestListIsDefaultFirstThenSorted(t *testing.T) { + home := setup(t) + + for _, name := range []string{"zeta", "alpha", "mid"} { + mustCreate(t, name) + } + + profiles, err := List() + require.NoError(t, err) + + var names []string + for _, p := range profiles { + names = append(names, p.Name) + } + + assert.Equal(t, []string{Default, "alpha", "mid", "zeta"}, names) + assert.Equal(t, home, profiles[0].Dir) +} + +func TestActiveRoundTrip(t *testing.T) { + setup(t) + mustCreate(t, "work") + + active, err := Active() + require.NoError(t, err) + assert.Equal(t, Default, active) + + require.NoError(t, SetActive("work")) + + active, err = Active() + require.NoError(t, err) + assert.Equal(t, "work", active) + + // Selecting the default profile clears the pointer rather than writing it. + require.NoError(t, SetActive(Default)) + + active, err = Active() + require.NoError(t, err) + assert.Equal(t, Default, active) +} + +// Removing the active profile must retire the pointer with it, or every later +// command fails on a dangling reference. +func TestRemoveClearsActivePointer(t *testing.T) { + setup(t) + + dir := mustCreate(t, "work") + require.NoError(t, SetActive("work")) + require.NoError(t, Remove("work")) + + assert.NoDirExists(t, dir) + + active, err := Active() + require.NoError(t, err) + assert.Equal(t, Default, active) +} + +func TestRemoveAndRenameRefuseTheDefaultProfile(t *testing.T) { + setup(t) + mustCreate(t, "work") + + assert.Error(t, Remove(Default)) + assert.Error(t, Rename(Default, "work2")) + assert.Error(t, Rename("work", Default)) +} + +func TestRenameCarriesTheActivePointer(t *testing.T) { + setup(t) + + mustCreate(t, "old") + require.NoError(t, SetActive("old")) + require.NoError(t, Rename("old", "new")) + + active, err := Active() + require.NoError(t, err) + assert.Equal(t, "new", active) + + exists, err := Exists("old") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestCreateRefusesDuplicatesAndDefault(t *testing.T) { + setup(t) + + mustCreate(t, "work") + + _, err := Create("work") + assert.Error(t, err) + + _, err = Create(Default) + assert.Error(t, err) +} + +func TestMetadataRoundTrip(t *testing.T) { + setup(t) + mustCreate(t, "work") + + require.NoError(t, WriteMetadata("work", Metadata{Email: "a@example.com"})) + + md, err := ReadMetadata("work") + require.NoError(t, err) + assert.Equal(t, "a@example.com", md.Email) + + // Metadata is a display convenience, so a profile without any reads as a + // zero value rather than an error. + mustCreate(t, "bare") + md, err = ReadMetadata("bare") + require.NoError(t, err) + assert.Empty(t, md.Email) +} + +// The profile management commands run through Fallback so a dangling +// reference cannot lock the user out of the commands that repair it. +func TestFallbackPointsAtDefaultAndKeepsTheCause(t *testing.T) { + home := setup(t) + + _, cause := Resolve(ResolveOptions{Flag: "ghost"}) + require.Error(t, cause) + + res, err := Fallback(cause) + require.NoError(t, err) + + assert.Equal(t, Default, res.Name) + assert.Equal(t, home, res.Dir) + assert.Equal(t, cause, res.Err) +} + +func TestFlagFromArgs(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"deploy", "--profile", "work"}, "work"}, + {[]string{"deploy", "--profile=work"}, "work"}, + {[]string{"--profile", "work", "deploy"}, "work"}, + {[]string{"deploy"}, ""}, + {[]string{"deploy", "--profile"}, ""}, + {[]string{"ssh", "console", "-C", "run --profile prod"}, ""}, + } + + for _, tc := range cases { + assert.Equal(t, tc.want, FlagFromArgs(tc.args), "args: %v", tc.args) + } +} diff --git a/profiles.md b/profiles.md new file mode 100644 index 0000000000..6ae9d5a133 --- /dev/null +++ b/profiles.md @@ -0,0 +1,82 @@ +# Credential profiles + +Profiles let one machine hold credentials for several Fly.io accounts at once and +pick between them per shell, per project or per command, instead of running +`fly auth logout` and `fly auth login` to switch. + +A profile is a complete flyctl config directory, not just a token, so each one +carries its own access token, metrics token, WireGuard peer state, agent socket and +lock files. `$HOME` is untouched, so Docker and SSH credentials keep working. + +The `default` profile *is* `~/.fly`. An installation that never runs `fly profile` +behaves exactly as before. + +## Usage + +```sh +fly profile add work # log an account into a new, isolated profile +fly profile add client-a + +cd ~/projects/acme +fly profile link client-a # writes .fly-profile for this directory tree + +fly deploy # uses client-a, with no switching and no flags +``` + +## Resolution order + +Highest precedence first: + +| # | Rule | Scope | +|---|------|-------| +| 1 | `FLY_CONFIG_DIR` | pins a config directory outright, bypassing profiles | +| 2 | `--profile ` | a single command | +| 3 | `FLY_PROFILE=` | a shell | +| 4 | `.fly-profile`, nearest at or above the working directory | a directory tree | +| 5 | `fly profile use ` | the machine | +| 6 | `default` | `~/.fly` | + +`FLY_ACCESS_TOKEN` and `FLY_API_TOKEN` continue to override the resolved config, as +before, so existing CI setups are unaffected. + +A profile named by any of these rules that does not exist is an error rather than a +silent fallback, since reaching a different account than the one asked for is worse +than not running. The `fly profile` commands are exempt so that a dangling reference +can be repaired; `fly profile show` reports what is wrong. + +## Commands + +``` +fly profile list [--refresh] list profiles, accounts and credential status +fly profile add [--token T] [--use] +fly profile use switch the machine-wide profile +fly profile link pin the current directory tree +fly profile show which profile is in effect, and why +fly profile rename +fly profile remove +``` + +`fly profile list` shows the account name from a local cache written at login; +`--refresh` re-checks each profile against the API, which also surfaces expired or +revoked tokens. + +## Layout + +``` +~/.fly/ +├── config.yml <- the "default" profile +├── active_profile <- written by `fly profile use` +└── profiles/ + └── work/ + ├── config.yml <- its own token and WireGuard state + └── profile.yml <- cached account email +``` + +`FLY_PROFILE_HOME` relocates the store, which is mainly useful for testing. + +## Scripted setup + +```sh +fly profile add prod --token "$FLY_PROD_TOKEN" +fly profile add staging --token "$FLY_STAGING_TOKEN" +```