From 728c7a4bb9d60053177566c2ae389c3207e1f32d Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Oct 2022 21:41:19 +0000 Subject: [PATCH 1/5] feat: add groups support to the CLI - Adds a route to get a group by org and name. --- cli/cliui/table.go | 3 + cli/create.go | 2 +- cli/login.go | 4 +- cli/logout.go | 2 +- cli/parameterslist.go | 2 +- cli/root.go | 8 +- cli/templatecreate.go | 2 +- cli/templatedelete.go | 2 +- cli/templateedit.go | 2 +- cli/templatelist.go | 4 +- cli/templatepull.go | 2 +- cli/templatepush.go | 2 +- cli/templateversions.go | 2 +- cli/usercreate.go | 2 +- coderd/httpmw/groupparam.go | 36 +++++++++ codersdk/groups.go | 17 +++++ enterprise/cli/groupcreate.go | 53 +++++++++++++ enterprise/cli/groupcreate_test.go | 36 +++++++++ enterprise/cli/groupdelete.go | 50 ++++++++++++ enterprise/cli/groupdelete_test.go | 71 +++++++++++++++++ enterprise/cli/groupedit.go | 113 +++++++++++++++++++++++++++ enterprise/cli/groupedit_test.go | 119 +++++++++++++++++++++++++++++ enterprise/cli/grouplist.go | 82 ++++++++++++++++++++ enterprise/cli/grouplist_test.go | 100 ++++++++++++++++++++++++ enterprise/cli/groups.go | 23 ++++++ enterprise/cli/root.go | 1 + enterprise/coderd/coderd.go | 8 ++ enterprise/coderd/groups_test.go | 20 +++++ 28 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 enterprise/cli/groupcreate.go create mode 100644 enterprise/cli/groupcreate_test.go create mode 100644 enterprise/cli/groupdelete.go create mode 100644 enterprise/cli/groupdelete_test.go create mode 100644 enterprise/cli/groupedit.go create mode 100644 enterprise/cli/groupedit_test.go create mode 100644 enterprise/cli/grouplist.go create mode 100644 enterprise/cli/grouplist_test.go create mode 100644 enterprise/cli/groups.go diff --git a/cli/cliui/table.go b/cli/cliui/table.go index cd417641f6892..bc77024a37a9b 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -166,6 +166,9 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) if val != nil { v = val.String() } + case []string: + //nolint + v = strings.Join(v.([]string), ", ") } rowSlice[i] = v diff --git a/cli/create.go b/cli/create.go index 793f4d99b4839..4b268f7161d8f 100644 --- a/cli/create.go +++ b/cli/create.go @@ -33,7 +33,7 @@ func create() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/login.go b/cli/login.go index 0667a251f38dc..c44727163ba1f 100644 --- a/cli/login.go +++ b/cli/login.go @@ -86,7 +86,7 @@ func login() *cobra.Command { return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err) } if !hasInitialUser { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Your Coder deployment hasn't been set up!\n") if username == "" { if !isTTY(cmd) { @@ -244,7 +244,7 @@ func login() *cobra.Command { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username)) return nil }, } diff --git a/cli/logout.go b/cli/logout.go index e0d01b306d0e5..d40a9ef45940c 100644 --- a/cli/logout.go +++ b/cli/logout.go @@ -67,7 +67,7 @@ func logout() *cobra.Command { errorString := strings.TrimRight(errorStringBuilder.String(), "\n") return xerrors.New("Failed to log out.\n" + errorString) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"You are no longer logged in. You can log in using 'coder login '.\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login '.\n") return nil }, } diff --git a/cli/parameterslist.go b/cli/parameterslist.go index 438b15acea419..3978df26a850f 100644 --- a/cli/parameterslist.go +++ b/cli/parameterslist.go @@ -27,7 +27,7 @@ func parameterList() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/root.go b/cli/root.go index 2682e164a48c5..2835103f7bf38 100644 --- a/cli/root.go +++ b/cli/root.go @@ -29,7 +29,7 @@ import ( ) var ( - caret = cliui.Styles.Prompt.String() + Caret = cliui.Styles.Prompt.String() // Applied as annotations to workspace commands // so they display in a separated "help" section. @@ -114,7 +114,7 @@ func Root(subcommands []*cobra.Command) *cobra.Command { Use: "coder", SilenceErrors: true, SilenceUsage: true, - Long: fmt.Sprintf(fmtLong, buildinfo.Version()), + Long: fmt.Sprintf(fmtLong, buildinfo.Version()), PersistentPreRun: func(cmd *cobra.Command, args []string) { if cliflag.IsSetBool(cmd, varNoVersionCheck) && cliflag.IsSetBool(cmd, varNoFeatureWarning) { @@ -328,8 +328,8 @@ func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) { return client, nil } -// currentOrganization returns the currently active organization for the authenticated user. -func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) { +// CurrentOrganization returns the currently active organization for the authenticated user. +func CurrentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) { orgs, err := client.OrganizationsByUser(cmd.Context(), codersdk.Me) if err != nil { return codersdk.Organization{}, nil diff --git a/cli/templatecreate.go b/cli/templatecreate.go index de0b8eab8f27e..7c0d25f0f7e2f 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -40,7 +40,7 @@ func templateCreate() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templatedelete.go b/cli/templatedelete.go index 8b1b1903a9b68..230bb4bc2662d 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -27,7 +27,7 @@ func templateDelete() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templateedit.go b/cli/templateedit.go index e48b509a42484..e0e4cf57a7196 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -29,7 +29,7 @@ func templateEdit() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/templatelist.go b/cli/templatelist.go index b7dc29ac497fb..e528687a7459b 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -20,7 +20,7 @@ func templateList() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } @@ -30,7 +30,7 @@ func templateList() *cobra.Command { } if len(templates) == 0 { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No templates found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name)) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No templates found in %s! Create one:\n\n", Caret, color.HiWhiteString(organization.Name)) _, _ = fmt.Fprintln(cmd.ErrOrStderr(), color.HiMagentaString(" $ coder templates create \n")) return nil } diff --git a/cli/templatepull.go b/cli/templatepull.go index 09f70c91b8f9e..19e5e04d82900 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -35,7 +35,7 @@ func templatePull() *cobra.Command { } // TODO(JonA): Do we need to add a flag for organization? - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index 40bafed0ef00c..9eed180667e7c 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -34,7 +34,7 @@ func templatePush() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templateversions.go b/cli/templateversions.go index c5111d0a16e17..3b13aea8f2c7d 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -45,7 +45,7 @@ func templateVersionsList() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/usercreate.go b/cli/usercreate.go index 73bc0fb8f9947..dd65282202060 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -25,7 +25,7 @@ func userCreate() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go index 13328cbcf1552..fb1dd39051d0e 100644 --- a/coderd/httpmw/groupparam.go +++ b/coderd/httpmw/groupparam.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -24,6 +25,41 @@ func GroupParam(r *http.Request) database.Group { return group } +func ExtractGroupByNameParam(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) { + var ( + ctx = r.Context() + org = OrganizationParam(r) + ) + + name := chi.URLParam(r, "groupName") + if name == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Missing group name in URL", + }) + return + } + + group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: org.ID, + Name: name, + }) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + ctx = context.WithValue(ctx, groupParamContextKey{}, group) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + // ExtraGroupParam grabs a group from the "group" URL parameter. func ExtractGroupParam(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/codersdk/groups.go b/codersdk/groups.go index a84f8560b2440..340fffaddd859 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -58,6 +58,23 @@ func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]G return groups, json.NewDecoder(res.Body).Decode(&groups) } +func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name string) (Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/groups/%s", orgID.String(), name), + nil, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/groups/%s", group.String()), diff --git a/enterprise/cli/groupcreate.go b/enterprise/cli/groupcreate.go new file mode 100644 index 0000000000000..b20728ab0af12 --- /dev/null +++ b/enterprise/cli/groupcreate.go @@ -0,0 +1,53 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/codersdk" +) + +func groupCreate() *cobra.Command { + var ( + avatarURL string + ) + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a user group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.CreateGroup(ctx, org.ID, codersdk.CreateGroupRequest{ + Name: args[0], + AvatarURL: avatarURL, + }) + if err != nil { + return xerrors.Errorf("create group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully created group %s", group.Name) + return nil + }, + } + + cliflag.StringVarP(cmd.Flags(), &avatarURL, "avatar-url", "u", "CODER_AVATAR_URL", "", "set an avatar for a group") + + return cmd +} diff --git a/enterprise/cli/groupcreate_test.go b/enterprise/cli/groupcreate_test.go new file mode 100644 index 0000000000000..137617e3df306 --- /dev/null +++ b/enterprise/cli/groupcreate_test.go @@ -0,0 +1,36 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" +) + +func TestCreateGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + + var ( + groupName = "test" + avatarURL = "https://example.com" + ) + + cmd, root := clitest.New(t, "groups", + "create", groupName, + "--avatar-url", avatarURL, + ) + + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.NoError(t, err) + }) +} diff --git a/enterprise/cli/groupdelete.go b/enterprise/cli/groupdelete.go new file mode 100644 index 0000000000000..4f2ab9a9b35ce --- /dev/null +++ b/enterprise/cli/groupdelete.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" +) + +func groupDelete() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a user group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + groupName = args[0] + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.GroupByOrgAndName(ctx, org.ID, groupName) + if err != nil { + return xerrors.Errorf("group by org and name: %w", err) + } + + err = client.DeleteGroup(ctx, group.ID) + if err != nil { + return xerrors.Errorf("patch group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted group %s!\n", cliui.Styles.Keyword.Render(group.Name)) + return nil + }, + } + + return cmd +} diff --git a/enterprise/cli/groupdelete_test.go b/enterprise/cli/groupdelete_test.go new file mode 100644 index 0000000000000..e2319a43e2aaa --- /dev/null +++ b/enterprise/cli/groupdelete_test.go @@ -0,0 +1,71 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupDelete(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "delete", group.Name, + ) + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully deleted group %s", cliui.Styles.Keyword.Render(group.Name))) + }) + + t.Run("NoArg", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "delete") + + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.Error(t, err) + }) +} diff --git a/enterprise/cli/groupedit.go b/enterprise/cli/groupedit.go new file mode 100644 index 0000000000000..c48c44ed1a421 --- /dev/null +++ b/enterprise/cli/groupedit.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "net/mail" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func groupEdit() *cobra.Command { + var ( + avatarURL string + name string + addUsers []string + rmUsers []string + ) + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit a user group.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + groupName = args[0] + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.GroupByOrgAndName(ctx, org.ID, groupName) + if err != nil { + return xerrors.Errorf("group by org and name: %w", err) + } + + req := codersdk.PatchGroupRequest{ + Name: name, + } + + if avatarURL != "" { + req.AvatarURL = &avatarURL + } + + users, err := client.Users(ctx, codersdk.UsersRequest{}) + if err != nil { + return xerrors.Errorf("get users: %w", err) + } + + req.AddUsers, err = convertToUserIDs(addUsers, users) + if err != nil { + return xerrors.Errorf("parse add-users: %w", err) + } + + req.RemoveUsers, err = convertToUserIDs(rmUsers, users) + if err != nil { + return xerrors.Errorf("parse rm-users: %w", err) + } + + group, err = client.PatchGroup(ctx, group.ID, req) + if err != nil { + return xerrors.Errorf("patch group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully patched group %s!\n", cliui.Styles.Keyword.Render(group.Name)) + return nil + }, + } + + cliflag.StringVarP(cmd.Flags(), &name, "name", "n", "", "", "Update the group name") + cliflag.StringVarP(cmd.Flags(), &avatarURL, "avatar-url", "u", "", "", "Update the group avatar") + cliflag.StringArrayVarP(cmd.Flags(), &addUsers, "add-users", "a", "", nil, "Add users to the group. Accepts emails or IDs.") + cliflag.StringArrayVarP(cmd.Flags(), &rmUsers, "rm-users", "r", "", nil, "Remove users to the group. Accepts emails or IDs.") + return cmd +} + +// convertToUserIDs accepts a list of users in the form of IDs or email addresses +// and translates any emails to the matching user ID. +func convertToUserIDs(userList []string, users []codersdk.User) ([]string, error) { + converted := make([]string, 0, len(userList)) + + for _, user := range userList { + if _, err := uuid.Parse(user); err == nil { + converted = append(converted, user) + continue + } + if _, err := mail.ParseAddress(user); err == nil { + for _, u := range users { + if u.Email == user { + converted = append(converted, u.ID.String()) + break + } + } + continue + } + + return nil, xerrors.Errorf("%q must be a valid UUID or email address", user) + } + + return converted, nil +} diff --git a/enterprise/cli/groupedit_test.go b/enterprise/cli/groupedit_test.go new file mode 100644 index 0000000000000..8c4c8f0f16e49 --- /dev/null +++ b/enterprise/cli/groupedit_test.go @@ -0,0 +1,119 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupEdit(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + // We use the sdk here as opposed to the CLI since adding this user + // is considered setup. They will be removed in the proper CLI test. + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user3.ID.String()}, + }) + require.NoError(t, err) + + var ( + expectedName = "beta" + ) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "edit", group.Name, + "--name", expectedName, + "--avatar-url", "https://example.com", + "-a", user1.ID.String(), + "-a", user2.Email, + "-r", user3.ID.String(), + ) + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully patched group %s", cliui.Styles.Keyword.Render(expectedName))) + }) + + t.Run("InvalidUserInput", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "edit", group.Name, + "-a", "foo", + ) + + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "must be a valid UUID or email address") + }) + + t.Run("NoArg", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "edit") + + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.Error(t, err) + }) +} diff --git a/enterprise/cli/grouplist.go b/enterprise/cli/grouplist.go new file mode 100644 index 0000000000000..cef981f7c1136 --- /dev/null +++ b/enterprise/cli/grouplist.go @@ -0,0 +1,82 @@ +package cli + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func groupList() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List user groups.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + groups, err := client.GroupsByOrganization(ctx, org.ID) + if err != nil { + return xerrors.Errorf("get groups: %w", err) + } + + if len(groups) == 0 { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No groups found in %s! Create one:\n\n", agpl.Caret, color.HiWhiteString(org.Name)) + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), color.HiMagentaString(" $ coder groups create \n")) + return nil + } + + out, err := displayGroups(groups...) + if err != nil { + return xerrors.Errorf("display groups: %w", err) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), out) + return nil + }, + } + return cmd +} + +type groupTableRow struct { + Name string `table:"name"` + OrganizationID uuid.UUID `table:"organization_id"` + Members []string `table:"members"` + AvatarURL string `table:"avatar_url"` +} + +func displayGroups(groups ...codersdk.Group) (string, error) { + rows := make([]groupTableRow, 0, len(groups)) + for _, group := range groups { + members := make([]string, 0, len(group.Members)) + for _, member := range group.Members { + members = append(members, member.Email) + } + rows = append(rows, groupTableRow{ + Name: group.Name, + OrganizationID: group.OrganizationID, + AvatarURL: group.AvatarURL, + Members: members, + }) + } + + return cliui.DisplayTable(rows, "name", nil) +} diff --git a/enterprise/cli/grouplist_test.go b/enterprise/cli/grouplist_test.go new file mode 100644 index 0000000000000..8740829a017c9 --- /dev/null +++ b/enterprise/cli/grouplist_test.go @@ -0,0 +1,100 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupList(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + + // We intentionally create the first group as beta so that we + // can assert that things are being sorted by name intentionally + // and not by chance (or some other parameter like created_at). + group1, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "beta", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + _, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String()}, + }) + require.NoError(t, err) + + _, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String()}, + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "list") + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + matches := []string{"NAME", "ORGANIZATION ID", "MEMBERS", " AVATAR URL", + group2.Name, group2.OrganizationID.String(), user2.Email, group2.AvatarURL, + group1.Name, group1.OrganizationID.String(), user1.Email, group1.AvatarURL, + } + + for _, match := range matches { + pty.ExpectMatch(match) + } + }) + + t.Run("NoGroups", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "list") + + pty := ptytest.New(t) + + cmd.SetErr(pty.Output()) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch("No groups found") + pty.ExpectMatch("coder groups create ") + }) +} diff --git a/enterprise/cli/groups.go b/enterprise/cli/groups.go new file mode 100644 index 0000000000000..d2acdb3d527bb --- /dev/null +++ b/enterprise/cli/groups.go @@ -0,0 +1,23 @@ +package cli + +import "github.com/spf13/cobra" + +func groups() *cobra.Command { + cmd := &cobra.Command{ + Use: "groups", + Short: "Manage groups", + Aliases: []string{"group"}, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + groupCreate(), + groupList(), + groupEdit(), + groupDelete(), + ) + + return cmd +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 52decb3266226..41337f14c77dd 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -11,6 +11,7 @@ func enterpriseOnly() []*cobra.Command { server(), features(), licenses(), + groups(), } } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b9d095883824e..6d8922ba6722a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -77,10 +77,18 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/organizations/{organization}/groups", func(r chi.Router) { r.Use( apiKeyMiddleware, + api.templateRBACEnabledMW, httpmw.ExtractOrganizationParam(api.Database), ) r.Post("/", api.postGroupByOrganization) r.Get("/", api.groups) + r.Route("/{groupName}", func(r chi.Router) { + r.Use( + httpmw.ExtractGroupByNameParam(api.Database), + ) + + r.Get("/", api.group) + }) }) r.Route("/templates/{template}/acl", func(r chi.Router) { diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 0cbec41be2a9f..2f800c1ad41ce 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -420,6 +420,26 @@ func TestGroup(t *testing.T) { require.Equal(t, group, ggroup) }) + t.Run("ByName", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client.GroupByOrgAndName(ctx, group.OrganizationID, group.Name) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + t.Run("WithUsers", func(t *testing.T) { t.Parallel() From c542f2e6dc4f4d74b0519aff248f4f5fdccc7f17 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Oct 2022 21:51:48 +0000 Subject: [PATCH 2/5] fix tests --- enterprise/cli/groupcreate.go | 3 ++- enterprise/cli/groupcreate_test.go | 16 ++++++++++++++-- enterprise/cli/groupedit.go | 2 +- enterprise/cli/grouplist.go | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/enterprise/cli/groupcreate.go b/enterprise/cli/groupcreate.go index b20728ab0af12..1a48d4af0810b 100644 --- a/enterprise/cli/groupcreate.go +++ b/enterprise/cli/groupcreate.go @@ -8,6 +8,7 @@ import ( agpl "github.com/coder/coder/cli" "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) @@ -42,7 +43,7 @@ func groupCreate() *cobra.Command { return xerrors.Errorf("create group: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully created group %s", group.Name) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully created group %s!\n", cliui.Styles.Keyword.Render(group.Name)) return nil }, } diff --git a/enterprise/cli/groupcreate_test.go b/enterprise/cli/groupcreate_test.go index 137617e3df306..2a0e61c6c0687 100644 --- a/enterprise/cli/groupcreate_test.go +++ b/enterprise/cli/groupcreate_test.go @@ -1,12 +1,17 @@ package cli_test import ( + "fmt" "testing" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" ) func TestCreateGroup(t *testing.T) { @@ -15,22 +20,29 @@ func TestCreateGroup(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := coderdenttest.New(t, nil) coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) var ( groupName = "test" avatarURL = "https://example.com" ) - cmd, root := clitest.New(t, "groups", + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "create", groupName, "--avatar-url", avatarURL, ) + pty := ptytest.New(t) + cmd.SetOut(pty.Output()) clitest.SetupConfig(t, client, root) err := cmd.Execute() require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully created group %s!", cliui.Styles.Keyword.Render(groupName))) }) } diff --git a/enterprise/cli/groupedit.go b/enterprise/cli/groupedit.go index c48c44ed1a421..d2881e98a6afb 100644 --- a/enterprise/cli/groupedit.go +++ b/enterprise/cli/groupedit.go @@ -23,7 +23,7 @@ func groupEdit() *cobra.Command { ) cmd := &cobra.Command{ Use: "edit ", - Short: "Edit a user group.", + Short: "Edit a user group", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var ( diff --git a/enterprise/cli/grouplist.go b/enterprise/cli/grouplist.go index cef981f7c1136..5fab5a26132dd 100644 --- a/enterprise/cli/grouplist.go +++ b/enterprise/cli/grouplist.go @@ -16,7 +16,7 @@ import ( func groupList() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List user groups.", + Short: "List user groups", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { var ( From d8fd43300131ec01c8b6d0c8c6d12804902c76bd Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Oct 2022 23:14:32 +0000 Subject: [PATCH 3/5] fix test --- coderd/httpmw/groupparam.go | 1 + enterprise/coderd/coderdenttest/coderdenttest_test.go | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go index fb1dd39051d0e..a513f811a6916 100644 --- a/coderd/httpmw/groupparam.go +++ b/coderd/httpmw/groupparam.go @@ -55,6 +55,7 @@ func ExtractGroupByNameParam(db database.Store) func(http.Handler) http.Handler } ctx = context.WithValue(ctx, groupParamContextKey{}, group) + chi.RouteContext(ctx).URLParams.Add("organization", group.OrganizationID.String()) next.ServeHTTP(rw, r.WithContext(ctx)) }) } diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index e8ad88cd02805..319c805163271 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -44,6 +44,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { a := coderdtest.NewAuthTester(ctx, t, client, api.AGPL, admin) a.URLParams["licenses/{id}"] = fmt.Sprintf("licenses/%d", license.ID) a.URLParams["groups/{group}"] = fmt.Sprintf("groups/%s", group.ID.String()) + a.URLParams["{groupName}"] = group.Name skipRoutes, assertRoute := coderdtest.AGPLRoutes(a) assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{ @@ -79,7 +80,11 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionRead, AssertObject: groupObj, } - assertRoute["PATCH:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ + assertRoute["GET:/api/v2/organizations/{organization}/groups/{groupName}"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + } + assertRoute["GET:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ AssertAction: rbac.ActionRead, AssertObject: groupObj, } From 3b78be41d82da0bd67aa34c3d67c8e2d1c68eb3c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Oct 2022 23:29:32 +0000 Subject: [PATCH 4/5] undo table update --- cli/cliui/table.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/cliui/table.go b/cli/cliui/table.go index bc77024a37a9b..cd417641f6892 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -166,9 +166,6 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) if val != nil { v = val.String() } - case []string: - //nolint - v = strings.Join(v.([]string), ", ") } rowSlice[i] = v From 349d591f918c212a313d88ee6dc5186e05c40307 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 27 Oct 2022 21:17:37 +0000 Subject: [PATCH 5/5] fix error msg --- enterprise/cli/groupdelete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/cli/groupdelete.go b/enterprise/cli/groupdelete.go index 4f2ab9a9b35ce..687d05c401c0c 100644 --- a/enterprise/cli/groupdelete.go +++ b/enterprise/cli/groupdelete.go @@ -38,7 +38,7 @@ func groupDelete() *cobra.Command { err = client.DeleteGroup(ctx, group.ID) if err != nil { - return xerrors.Errorf("patch group: %w", err) + return xerrors.Errorf("delete group: %w", err) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted group %s!\n", cliui.Styles.Keyword.Render(group.Name)) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy