From 31a854198f7675ed5712753017cac38b6e669934 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 21 Feb 2024 17:12:10 -0600 Subject: [PATCH 01/15] feat: begin work to implement switching orgs in the cli --- cli/config/file.go | 8 +++++ cli/create.go | 2 +- cli/organization.go | 68 +++++++++++++++++++++++++++++++++++ cli/root.go | 41 ++++++++++++++++++--- cli/templatecreate.go | 2 +- cli/templatedelete.go | 2 +- cli/templateedit.go | 2 +- cli/templatelist.go | 2 +- cli/templatepull.go | 2 +- cli/templatepush.go | 2 +- cli/templateversionarchive.go | 4 +-- cli/templateversions.go | 2 +- cli/usercreate.go | 2 +- codersdk/organizations.go | 10 +++--- enterprise/cli/groupcreate.go | 2 +- enterprise/cli/groupdelete.go | 2 +- enterprise/cli/groupedit.go | 2 +- enterprise/cli/grouplist.go | 2 +- 18 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 cli/organization.go diff --git a/cli/config/file.go b/cli/config/file.go index 908af1aac804b..48ca471217583 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -70,6 +70,14 @@ func (r Root) PostgresPort() File { // File provides convenience methods for interacting with *os.File. type File string +func (f File) Exists() bool { + if f == "" { + return false + } + _, err := os.Stat(string(f)) + return err == nil +} + // Delete deletes the file. func (f File) Delete() error { if f == "" { diff --git a/cli/create.go b/cli/create.go index 1a2492374a186..a923dc0d9a5c7 100644 --- a/cli/create.go +++ b/cli/create.go @@ -43,7 +43,7 @@ func (r *RootCmd) create() *clibase.Cmd { ), Middleware: clibase.Chain(r.InitClient(client)), Handler: func(inv *clibase.Invocation) error { - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/organization.go b/cli/organization.go new file mode 100644 index 0000000000000..d2485973c50f0 --- /dev/null +++ b/cli/organization.go @@ -0,0 +1,68 @@ +package cli + +import ( + "fmt" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +func (r *RootCmd) organizations() *clibase.Cmd { + cmd := &clibase.Cmd{ + Annotations: workspaceCommand, + Use: "organizations { current }", + Short: "Organization related commands", + Aliases: []string{"organization", "org", "orgs"}, + + Handler: func(inv *clibase.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*clibase.Cmd{ + r.currentOrganization(), + }, + } + + cmd.Options = clibase.OptionSet{} + return cmd +} + +func (r *RootCmd) currentOrganization() *clibase.Cmd { + var ( + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( + cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { + typed := data.([]codersdk.Organization) + if len(typed) != 1 { + return "", fmt.Errorf("expected 1 organization, got %d", len(typed)) + } + return fmt.Sprintf("Current organization: %s (%s)\n", typed[0].Name, typed[0].ID.String()), nil + }), + cliui.TableFormat([]codersdk.Organization{}, []string{"id", "name", "default"}), + cliui.JSONFormat(), + ) + ) + cmd := &clibase.Cmd{ + Use: "current", + Short: "Show the current selected organization the cli will use", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Handler: func(inv *clibase.Invocation) error { + org, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + + out, err := formatter.Format(inv.Context(), []codersdk.Organization{org}) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + formatter.AttachOptions(&cmd.Options) + + return cmd +} diff --git a/cli/root.go b/cli/root.go index 2bf01095573ee..829cec09e8353 100644 --- a/cli/root.go +++ b/cli/root.go @@ -94,6 +94,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.tokens(), r.users(), r.version(defaultVersionInfo), + r.organizations(), // Workspace Commands r.autoupdate(), @@ -698,14 +699,44 @@ func (r *RootCmd) createAgentClient() (*agentsdk.Client, error) { } // CurrentOrganization returns the currently active organization for the authenticated user. -func CurrentOrganization(inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) { +func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) { + conf := r.createConfig() + selected := "" + if conf.Organization().Exists() { + org, err := conf.Organization().Read() + if err != nil { + return codersdk.Organization{}, fmt.Errorf("read selected organization from config file %q: %w", conf.Organization(), err) + } + selected = org + } + + // Verify the org exists and the user is a member orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) if err != nil { - return codersdk.Organization{}, nil + return codersdk.Organization{}, err } - // For now, we won't use the config to set this. - // Eventually, we will support changing using "coder switch " - return orgs[0], nil + + // User manually selected an organization + if selected != "" { + index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { + return org.Name == selected || org.ID.String() == selected + }) + + if index < 0 { + return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected) + } + return orgs[index], nil + } + + // User did not select an organization, so use the default. + index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { + return org.IsDefault + }) + if index < 0 { + return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder switch ' to select an organization to use") + } + + return orgs[index], nil } func splitNamedWorkspace(identifier string) (owner string, workspaceName string, err error) { diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 5a03ff12677ac..4e379ef67a2e8 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -69,7 +69,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { } } - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/templatedelete.go b/cli/templatedelete.go index e15fe4bd48722..ac6603815b115 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -32,7 +32,7 @@ func (r *RootCmd) templateDelete() *clibase.Cmd { templates = []codersdk.Template{} ) - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/templateedit.go b/cli/templateedit.go index c7ac3b430b897..b28375eabb818 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -79,7 +79,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { } } - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/templatelist.go b/cli/templatelist.go index 6e18f8462555e..d6fcbd77dafff 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -25,7 +25,7 @@ func (r *RootCmd) templateList() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/templatepull.go b/cli/templatepull.go index 2509cc9df4281..36fd214561f00 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -44,7 +44,7 @@ func (r *RootCmd) templatePull() *clibase.Cmd { return xerrors.Errorf("either tar or zip can be selected") } - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index e0e1e689c4017..7e60a7bcb9ece 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -46,7 +46,7 @@ func (r *RootCmd) templatePush() *clibase.Cmd { Handler: func(inv *clibase.Invocation) error { uploadFlags.setWorkdir(workdir) - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go index 63c9d8a3de212..422380902990a 100644 --- a/cli/templateversionarchive.go +++ b/cli/templateversionarchive.go @@ -47,7 +47,7 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *clibase.Cmd { versions []codersdk.TemplateVersion ) - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } @@ -121,7 +121,7 @@ func (r *RootCmd) archiveTemplateVersions() *clibase.Cmd { templates = []codersdk.Template{} ) - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/cli/templateversions.go b/cli/templateversions.go index a27d6a6d65af3..1fc0902a7d7ce 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -93,7 +93,7 @@ func (r *RootCmd) templateVersionsList() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/usercreate.go b/cli/usercreate.go index 478cc98e16e47..8fe7027eac05b 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -31,7 +31,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - organization, err := CurrentOrganization(inv, client) + organization, err := CurrentOrganization(r, inv, client) if err != nil { return err } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 8bf1ecc96811d..10a0cfb4f673f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -26,11 +26,11 @@ const ( // Organization is the JSON representation of a Coder organization. type Organization struct { - ID uuid.UUID `json:"id" validate:"required" format:"uuid"` - Name string `json:"name" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` - IsDefault bool `json:"is_default" validate:"required"` + ID uuid.UUID `table:"id" json:"id" validate:"required" format:"uuid"` + Name string `table:"name,default_sort" json:"name" validate:"required"` + CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"` + UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"` + IsDefault bool `table:"default" json:"is_default" validate:"required"` } type OrganizationMember struct { diff --git a/enterprise/cli/groupcreate.go b/enterprise/cli/groupcreate.go index e5f7bbd8a3bb9..8a83b1551c2dc 100644 --- a/enterprise/cli/groupcreate.go +++ b/enterprise/cli/groupcreate.go @@ -29,7 +29,7 @@ func (r *RootCmd) groupCreate() *clibase.Cmd { Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() - org, err := agpl.CurrentOrganization(inv, client) + org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } diff --git a/enterprise/cli/groupdelete.go b/enterprise/cli/groupdelete.go index e7ca01ba36de8..4fab590178940 100644 --- a/enterprise/cli/groupdelete.go +++ b/enterprise/cli/groupdelete.go @@ -27,7 +27,7 @@ func (r *RootCmd) groupDelete() *clibase.Cmd { groupName = inv.Args[0] ) - org, err := agpl.CurrentOrganization(inv, client) + org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } diff --git a/enterprise/cli/groupedit.go b/enterprise/cli/groupedit.go index 8811378bc0a34..b6c730d395a1d 100644 --- a/enterprise/cli/groupedit.go +++ b/enterprise/cli/groupedit.go @@ -37,7 +37,7 @@ func (r *RootCmd) groupEdit() *clibase.Cmd { groupName = inv.Args[0] ) - org, err := agpl.CurrentOrganization(inv, client) + org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } diff --git a/enterprise/cli/grouplist.go b/enterprise/cli/grouplist.go index 78bcb28ca13ac..92a45988baf3c 100644 --- a/enterprise/cli/grouplist.go +++ b/enterprise/cli/grouplist.go @@ -30,7 +30,7 @@ func (r *RootCmd) groupList() *clibase.Cmd { Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() - org, err := agpl.CurrentOrganization(inv, client) + org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } From 1ac16cf14047ebc6401c2781ade04fdbabc6a297 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 21 Feb 2024 17:33:15 -0600 Subject: [PATCH 02/15] add only-id option --- cli/organization.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index d2485973c50f0..3bdc417f43766 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -41,6 +41,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { cliui.TableFormat([]codersdk.Organization{}, []string{"id", "name", "default"}), cliui.JSONFormat(), ) + onlyID = false ) cmd := &clibase.Cmd{ Use: "current", @@ -48,17 +49,30 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { Middleware: clibase.Chain( r.InitClient(client), ), + Options: clibase.OptionSet{ + { + Name: "only-id", + Description: "Only print the organization ID.", + Required: false, + Flag: "only-id", + Value: clibase.BoolOf(&onlyID), + }, + }, Handler: func(inv *clibase.Invocation) error { org, err := CurrentOrganization(r, inv, client) if err != nil { return err } - out, err := formatter.Format(inv.Context(), []codersdk.Organization{org}) - if err != nil { - return err + if onlyID { + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", org.ID) + } else { + out, err := formatter.Format(inv.Context(), []codersdk.Organization{org}) + if err != nil { + return err + } + _, err = fmt.Fprintf(inv.Stdout, out) } - _, err = fmt.Fprintln(inv.Stdout, out) return nil }, } From 6a99a37a533ab7df1c622d88c7a702bad30c285d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 08:58:27 -0600 Subject: [PATCH 03/15] Mark hidden --- cli/organization.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 3bdc417f43766..215f6b7c77abe 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -14,7 +14,7 @@ func (r *RootCmd) organizations() *clibase.Cmd { Use: "organizations { current }", Short: "Organization related commands", Aliases: []string{"organization", "org", "orgs"}, - + Hidden: true, // Hidden until these commands are complete. Handler: func(inv *clibase.Invocation) error { return inv.Command.HelpHandler(inv) }, @@ -45,7 +45,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { ) cmd := &clibase.Cmd{ Use: "current", - Short: "Show the current selected organization the cli will use", + Short: "Show the current selected organization the cli will use.", Middleware: clibase.Chain( r.InitClient(client), ), From d88d7bee3982c415d55c39c1d890bb89477ce80e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 09:10:38 -0600 Subject: [PATCH 04/15] linting --- cli/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index 215f6b7c77abe..80019705b0a15 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -71,7 +71,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { if err != nil { return err } - _, err = fmt.Fprintf(inv.Stdout, out) + _, _ = fmt.Fprintf(inv.Stdout, out) } return nil }, From 5df951b96aaa8e5b302c6a337682d2103d78b8b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 09:35:05 -0600 Subject: [PATCH 05/15] fix test assert --- cli/templatecreate_test.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 0eaf1344ea298..9710a86a88b44 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -243,19 +243,7 @@ func TestTemplateCreate(t *testing.T) { assert.Error(t, err) }() - matches := []struct { - match string - write string - }{ - {match: "Upload", write: "yes"}, - } - for _, m := range matches { - pty.ExpectMatch(m.match) - if len(m.write) > 0 { - pty.WriteLine(m.write) - } - } - + pty.ExpectMatch("context canceled") <-ctx.Done() }) From a2f68eafa836339984e7e53c952354fe01bfaeca Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 09:54:40 -0600 Subject: [PATCH 06/15] linting --- cli/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index 80019705b0a15..84641eb6d3b08 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -71,7 +71,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { if err != nil { return err } - _, _ = fmt.Fprintf(inv.Stdout, out) + _, _ = fmt.Fprint(inv.Stdout, out) } return nil }, From 50cdb32fccf0af6f262008be6279208d8787fea2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 10:00:32 -0600 Subject: [PATCH 07/15] Lint --- cli/organization.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index 84641eb6d3b08..c0bcb0c79d456 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -32,7 +32,11 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { client = new(codersdk.Client) formatter = cliui.NewOutputFormatter( cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { - typed := data.([]codersdk.Organization) + typed, ok := data.([]codersdk.Organization) + if !ok { + // This should never happen + return "", fmt.Errorf("expected []Organization, got %T", data) + } if len(typed) != 1 { return "", fmt.Errorf("expected 1 organization, got %d", len(typed)) } From 438e69dd200464c85ce048cb66120c07af6b0701 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 10:09:53 -0600 Subject: [PATCH 08/15] Add unit test --- cli/organization_test.go | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 cli/organization_test.go diff --git a/cli/organization_test.go b/cli/organization_test.go new file mode 100644 index 0000000000000..a12f2ecc13172 --- /dev/null +++ b/cli/organization_test.go @@ -0,0 +1,42 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestCurrentOrganization(t *testing.T) { + t.Parallel() + + t.Run("OnlyID", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + 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) + } + + inv, root := clitest.New(t, "organizations", "current", "--only-id") + 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(first.OrganizationID.String()) + }) +} From f32c78cd331dbdf4405066e3b9103d5faed3b406 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 10:17:24 -0600 Subject: [PATCH 09/15] linting --- cli/organization_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/organization_test.go b/cli/organization_test.go index a12f2ecc13172..727f37e666ca4 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -7,6 +7,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" @@ -17,8 +18,10 @@ func TestCurrentOrganization(t *testing.T) { t.Run("OnlyID", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) + 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"} From cad75c22fc1f76a5d31aab337ccef42fc736141e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 10:41:47 -0600 Subject: [PATCH 10/15] fix use --- cli/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index c0bcb0c79d456..8b7862f6b4689 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -11,7 +11,7 @@ import ( func (r *RootCmd) organizations() *clibase.Cmd { cmd := &clibase.Cmd{ Annotations: workspaceCommand, - Use: "organizations { current }", + Use: "organizations [subcommand]", Short: "Organization related commands", Aliases: []string{"organization", "org", "orgs"}, Hidden: true, // Hidden until these commands are complete. From f8b97d981d9f2392094ae548f1df313f052189c3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:30:16 -0600 Subject: [PATCH 11/15] implement more general organizations show --- cli/organization.go | 71 ++++++++++++++++++++----- coderd/httpmw/organizationparam.go | 27 ++++++++-- coderd/httpmw/organizationparam_test.go | 18 +++++-- codersdk/organizations.go | 10 +++- codersdk/users.go | 2 +- 5 files changed, 104 insertions(+), 24 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 8b7862f6b4689..e76fe9e53e045 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" @@ -29,18 +30,16 @@ func (r *RootCmd) organizations() *clibase.Cmd { func (r *RootCmd) currentOrganization() *clibase.Cmd { var ( - client = new(codersdk.Client) - formatter = cliui.NewOutputFormatter( + stringFormat func(orgs []codersdk.Organization) (string, error) + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { typed, ok := data.([]codersdk.Organization) if !ok { // This should never happen return "", fmt.Errorf("expected []Organization, got %T", data) } - if len(typed) != 1 { - return "", fmt.Errorf("expected 1 organization, got %d", len(typed)) - } - return fmt.Sprintf("Current organization: %s (%s)\n", typed[0].Name, typed[0].ID.String()), nil + return stringFormat(typed) }), cliui.TableFormat([]codersdk.Organization{}, []string{"id", "name", "default"}), cliui.JSONFormat(), @@ -48,10 +47,11 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { onlyID = false ) cmd := &clibase.Cmd{ - Use: "current", - Short: "Show the current selected organization the cli will use.", + Use: "show [current|me|uuid]", + Short: "Show organization information. By default, if no argument is provided, the current cli configured organization is shown.", Middleware: clibase.Chain( r.InitClient(client), + clibase.RequireRangeArgs(0, 1), ), Options: clibase.OptionSet{ { @@ -63,15 +63,60 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - org, err := CurrentOrganization(r, inv, client) - if err != nil { - return err + orgArg := "current" + if len(inv.Args) == 1 { + orgArg = inv.Args[0] + } + + var orgs []codersdk.Organization + var err error + switch strings.ToLower(orgArg) { + case "current": + stringFormat = func(orgs []codersdk.Organization) (string, error) { + if len(orgs) != 1 { + return "", fmt.Errorf("expected 1 organization, got %d", len(orgs)) + } + return fmt.Sprintf("Current CLI Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil + } + org, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + orgs = []codersdk.Organization{org} + case "me": + stringFormat = func(orgs []codersdk.Organization) (string, error) { + var str strings.Builder + _, _ = fmt.Fprint(&str, "Organizations you are a member of:\n") + for _, org := range orgs { + _, _ = fmt.Fprintf(&str, "\t%s (%s)\n", org.Name, org.ID.String()) + } + return str.String(), nil + } + orgs, err = client.OrganizationsByUser(inv.Context(), codersdk.Me) + if err != nil { + return err + } + default: + stringFormat = func(orgs []codersdk.Organization) (string, error) { + if len(orgs) != 1 { + return "", fmt.Errorf("expected 1 organization, got %d", len(orgs)) + } + return fmt.Sprintf("Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil + } + // This works for a uuid or a name + org, err := client.OrganizationByName(inv.Context(), orgArg) + if err != nil { + return err + } + orgs = []codersdk.Organization{org} } if onlyID { - _, _ = fmt.Fprintf(inv.Stdout, "%s\n", org.ID) + for _, org := range orgs { + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", org.ID) + } } else { - out, err := formatter.Format(inv.Context(), []codersdk.Organization{org}) + out, err := formatter.Format(inv.Context(), orgs) if err != nil { return err } diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 0637fba3dc04b..c219751e2b8fc 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -2,8 +2,12 @@ package httpmw import ( "context" + "fmt" "net/http" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" @@ -40,19 +44,34 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - orgID, ok := ParseUUIDParam(rw, r, "organization") - if !ok { + arg := chi.URLParam(r, "organization") + if arg == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "\"organization\" must be provided.", + }) return } - organization, err := db.GetOrganizationByID(ctx, orgID) + var organization database.Organization + var err error + // Try by name or uuid. + id, err := uuid.Parse(arg) + if err == nil { + organization, err = db.GetOrganizationByID(ctx, id) + } else { + organization, err = db.GetOrganizationByName(ctx, arg) + } if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("Organization %q not found.", arg), + Detail: "Provide either the organization id or name.", + }) return } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching organization.", + Message: fmt.Sprintf("Internal error fetching organization %q.", arg), Detail: err.Error(), }) return diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index d9cf0c79130c8..e5415d1348906 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -103,7 +103,7 @@ func TestOrganizationParam(t *testing.T) { rtr.ServeHTTP(rw, r) res := rw.Result() defer res.Body.Close() - require.Equal(t, http.StatusBadRequest, res.StatusCode) + require.Equal(t, http.StatusNotFound, res.StatusCode) }) t.Run("NotInOrganization", func(t *testing.T) { @@ -160,8 +160,6 @@ func TestOrganizationParam(t *testing.T) { }) require.NoError(t, err) - chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String()) - chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String()) rtr.Use( httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ DB: db, @@ -194,9 +192,21 @@ func TestOrganizationParam(t *testing.T) { assert.NotEmpty(t, orgMem.OrganizationMember.UserID) assert.NotEmpty(t, orgMem.OrganizationMember.Roles) }) + + // Try by ID + chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String()) + chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String()) rtr.ServeHTTP(rw, r) res := rw.Result() defer res.Body.Close() - require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, http.StatusOK, res.StatusCode, "by id") + + // Try by name + chi.RouteContext(r.Context()).URLParams.Add("organization", organization.Name) + chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String()) + rtr.ServeHTTP(rw, r) + res = rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode, "by name") }) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 10a0cfb4f673f..55e2a6b1ab77e 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -153,8 +153,8 @@ type CreateWorkspaceRequest struct { AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"` } -func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil) +func (c *Client) OrganizationByName(ctx context.Context, name string) (Organization, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", name), nil) if err != nil { return Organization{}, xerrors.Errorf("execute request: %w", err) } @@ -168,6 +168,12 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, return organization, json.NewDecoder(res.Body).Decode(&organization) } +func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) { + // OrganizationByName uses the exact same endpoint. It accepts a name or uuid. + // We just provide this function for type safety. + return c.OrganizationByName(ctx, id.String()) +} + // ProvisionerDaemons returns provisioner daemons available. func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) { res, err := c.Request(ctx, http.MethodGet, diff --git a/codersdk/users.go b/codersdk/users.go index b42eee38af955..1d50df1472dbf 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -573,7 +573,7 @@ func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organi return orgs, json.NewDecoder(res.Body).Decode(&orgs) } -func (c *Client) OrganizationByName(ctx context.Context, user string, name string) (Organization, error) { +func (c *Client) OrganizationByUserAndName(ctx context.Context, user string, name string) (Organization, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil) if err != nil { return Organization{}, err From 405afc938791a7384695de08264b81b799b2d9e9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:32:32 -0600 Subject: [PATCH 12/15] fix short desc --- cli/organization.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index e76fe9e53e045..455c0e9988e6d 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -48,7 +48,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { ) cmd := &clibase.Cmd{ Use: "show [current|me|uuid]", - Short: "Show organization information. By default, if no argument is provided, the current cli configured organization is shown.", + Short: "Show the organization, if no argument is given, the organization currently in use will be shown.", Middleware: clibase.Chain( r.InitClient(client), clibase.RequireRangeArgs(0, 1), @@ -64,7 +64,7 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd { }, Handler: func(inv *clibase.Invocation) error { orgArg := "current" - if len(inv.Args) == 1 { + if len(inv.Args) >= 1 { orgArg = inv.Args[0] } From a9da679d0b80ed3d966688e179ff7633862da8d0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:33:35 -0600 Subject: [PATCH 13/15] fix help msg --- cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/root.go b/cli/root.go index 829cec09e8353..4d78575c75c1e 100644 --- a/cli/root.go +++ b/cli/root.go @@ -733,7 +733,7 @@ func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.C return org.IsDefault }) if index < 0 { - return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder switch ' to select an organization to use") + return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder organizations switch ' to select an organization to use") } return orgs[index], nil From 510dbb8be36d644349e4d11a7d7c0ff5c6d35234 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:59:33 -0600 Subject: [PATCH 14/15] Fix test --- coderd/organizations_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index b146925667cbf..e176c7a6d858c 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -66,7 +66,7 @@ func TestOrganizationByUserAndName(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.OrganizationByName(ctx, codersdk.Me, "nothing") + _, err := client.OrganizationByUserAndName(ctx, codersdk.Me, "nothing") var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) @@ -85,7 +85,7 @@ func TestOrganizationByUserAndName(t *testing.T) { Name: "another", }) require.NoError(t, err) - _, err = other.OrganizationByName(ctx, codersdk.Me, org.Name) + _, err = other.OrganizationByUserAndName(ctx, codersdk.Me, org.Name) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) @@ -101,7 +101,7 @@ func TestOrganizationByUserAndName(t *testing.T) { org, err := client.Organization(ctx, user.OrganizationID) require.NoError(t, err) - _, err = client.OrganizationByName(ctx, codersdk.Me, org.Name) + _, err = client.OrganizationByUserAndName(ctx, codersdk.Me, org.Name) require.NoError(t, err) }) } From c74106b2d7f246fc8520c1399a443cf46411571b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 11:12:00 -0600 Subject: [PATCH 15/15] whoops, fix the unit test --- cli/organization_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization_test.go b/cli/organization_test.go index 727f37e666ca4..658498883ece8 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -32,7 +32,7 @@ func TestCurrentOrganization(t *testing.T) { require.NoError(t, err) } - inv, root := clitest.New(t, "organizations", "current", "--only-id") + inv, root := clitest.New(t, "organizations", "show", "--only-id") clitest.SetupConfig(t, client, root) pty := ptytest.New(t).Attach(inv) errC := make(chan error)