feat: set organization context in coder organizations (#12265)

* feat: add coder organizations set to change org context

`coder organizations set <org>`
This commit is contained in:
Steven Masley
2024-02-26 11:39:26 -06:00
committed by GitHub
parent 748cf4b2c4
commit 70ccefc357
3 changed files with 216 additions and 3 deletions

View File

@ -1,12 +1,17 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"os"
"slices"
"strings" "strings"
"github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
) )
func (r *RootCmd) organizations() *clibase.Cmd { func (r *RootCmd) organizations() *clibase.Cmd {
@ -21,6 +26,7 @@ func (r *RootCmd) organizations() *clibase.Cmd {
}, },
Children: []*clibase.Cmd{ Children: []*clibase.Cmd{
r.currentOrganization(), r.currentOrganization(),
r.switchOrganization(),
}, },
} }
@ -28,6 +34,175 @@ func (r *RootCmd) organizations() *clibase.Cmd {
return cmd return cmd
} }
func (r *RootCmd) switchOrganization() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "set <organization name | ID>",
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + formatExamples(
example{
Description: "Remove the current organization and defer to the default.",
Command: "coder organizations set ''",
},
example{
Description: "Switch to a custom organization.",
Command: "coder organizations set my-org",
},
),
Middleware: clibase.Chain(
r.InitClient(client),
clibase.RequireRangeArgs(0, 1),
),
Options: clibase.OptionSet{},
Handler: func(inv *clibase.Invocation) error {
conf := r.createConfig()
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return fmt.Errorf("failed to get organizations: %w", err)
}
// Keep the list of orgs sorted
slices.SortFunc(orgs, func(a, b codersdk.Organization) int {
return strings.Compare(a.Name, b.Name)
})
var switchToOrg string
if len(inv.Args) == 0 {
// Pull switchToOrg from a prompt selector, rather than command line
// args.
switchToOrg, err = promptUserSelectOrg(inv, conf, orgs)
if err != nil {
return err
}
} else {
switchToOrg = inv.Args[0]
}
// If the user passes an empty string, we want to remove the organization
// from the config file. This will defer to default behavior.
if switchToOrg == "" {
err := conf.Organization().Delete()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to unset organization: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Organization unset\n")
} else {
// Find the selected org in our list.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == switchToOrg || org.ID.String() == switchToOrg
})
if index < 0 {
// Using this error for better error message formatting
err := &codersdk.Error{
Response: codersdk.Response{
Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg),
Detail: "Ensure the organization argument is correct and you are a member of it.",
},
Helper: fmt.Sprintf("Valid organizations you can switch to: %s", strings.Join(orgNames(orgs), ", ")),
}
return err
}
// Always write the uuid to the config file. Names can change.
err := conf.Organization().Write(orgs[index].ID.String())
if err != nil {
return fmt.Errorf("failed to write organization to config file: %w", err)
}
}
// Verify it worked.
current, err := CurrentOrganization(r, inv, client)
if err != nil {
// An SDK error could be a permission error. So offer the advice to unset the org
// and reset the context.
var sdkError *codersdk.Error
if errors.As(err, &sdkError) {
if sdkError.Helper == "" && sdkError.StatusCode() != 500 {
sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations set ""'`
}
return sdkError
}
return fmt.Errorf("failed to get current organization: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Current organization context set to %s (%s)\n", current.Name, current.ID.String())
return nil
},
}
return cmd
}
// promptUserSelectOrg will prompt the user to select an organization from a list
// of their organizations.
func promptUserSelectOrg(inv *clibase.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
// Default choice
var defaultOrg string
// Comes from config file
if conf.Organization().Exists() {
defaultOrg, _ = conf.Organization().Read()
}
// No config? Comes from default org in the list
if defaultOrg == "" {
defIndex := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.IsDefault
})
if defIndex >= 0 {
defaultOrg = orgs[defIndex].Name
}
}
// Defer to first org
if defaultOrg == "" && len(orgs) > 0 {
defaultOrg = orgs[0].Name
}
// Ensure the `defaultOrg` value is an org name, not a uuid.
// If it is a uuid, change it to the org name.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.ID.String() == defaultOrg || org.Name == defaultOrg
})
if index >= 0 {
defaultOrg = orgs[index].Name
}
// deselectOption is the option to delete the organization config file and defer
// to default behavior.
const deselectOption = "[Default]"
if defaultOrg == "" {
defaultOrg = deselectOption
}
// Pull value from a prompt
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to set the current CLI context to:"))
value, err := cliui.Select(inv, cliui.SelectOptions{
Options: append([]string{deselectOption}, orgNames(orgs)...),
Default: defaultOrg,
Size: 10,
HideSearch: false,
})
if err != nil {
return "", err
}
// Deselect is an alias for ""
if value == deselectOption {
value = ""
}
return value, nil
}
// orgNames is a helper function to turn a list of organizations into a list of
// their names as strings.
func orgNames(orgs []codersdk.Organization) []string {
names := make([]string, 0, len(orgs))
for _, org := range orgs {
names = append(names, org.Name)
}
return names
}
func (r *RootCmd) currentOrganization() *clibase.Cmd { func (r *RootCmd) currentOrganization() *clibase.Cmd {
var ( var (
stringFormat func(orgs []codersdk.Organization) (string, error) stringFormat func(orgs []codersdk.Organization) (string, error)

View File

@ -74,3 +74,37 @@ func TestCurrentOrganization(t *testing.T) {
pty.ExpectMatch(orgs["bar"].ID.String()) pty.ExpectMatch(orgs["bar"].ID.String())
}) })
} }
func TestOrganizationSwitch(t *testing.T) {
t.Parallel()
t.Run("Switch", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, ownerClient)
// Owner is required to make orgs
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
ctx := testutil.Context(t, testutil.WaitMedium)
orgs := []string{"foo", "bar"}
for _, orgName := range orgs {
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: orgName,
})
require.NoError(t, err)
}
exp, err := client.OrganizationByName(ctx, "foo")
require.NoError(t, err)
inv, root := clitest.New(t, "organizations", "set", "foo")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(exp.ID.String())
})
}

View File

@ -743,7 +743,7 @@ func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.C
return org.IsDefault return org.IsDefault
}) })
if index < 0 { if index < 0 {
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder organizations switch <org>' to select an organization to use") return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder set <org>' to select an organization to use")
} }
return orgs[index], nil return orgs[index], nil
@ -1202,8 +1202,12 @@ func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) strin
func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string { func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string {
var str strings.Builder var str strings.Builder
if opts.Verbose { if opts.Verbose {
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode()))) // If all these fields are empty, then do not print this information.
_, _ = str.WriteString("\n") // This can occur if the error is being used outside the api.
if !(err.Method() == "" && err.URL() == "" && err.StatusCode() == 0) {
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode())))
_, _ = str.WriteString("\n")
}
} }
// Always include this trace. Users can ignore this. // Always include this trace. Users can ignore this.
if from != "" { if from != "" {