feat: add groups support to the CLI (#4755)

This commit is contained in:
Jon Ayers 2022-10-27 16:49:35 -05:00 committed by GitHub
parent ce2a7d49b1
commit 90f77a3415
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 766 additions and 18 deletions

View File

@ -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
}

View File

@ -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
},
}

View File

@ -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 <url>'.\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
return nil
},
}

View File

@ -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)
}

View File

@ -30,7 +30,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.
@ -352,8 +352,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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 <directory>\n"))
return nil
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,42 @@ 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)
chi.RouteContext(ctx).URLParams.Add("organization", group.OrganizationID.String())
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 {

View File

@ -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()),

View File

@ -0,0 +1,54 @@
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/cli/cliui"
"github.com/coder/coder/codersdk"
)
func groupCreate() *cobra.Command {
var (
avatarURL string
)
cmd := &cobra.Command{
Use: "create <name>",
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!\n", cliui.Styles.Keyword.Render(group.Name))
return nil
},
}
cliflag.StringVarP(cmd.Flags(), &avatarURL, "avatar-url", "u", "CODER_AVATAR_URL", "", "set an avatar for a group")
return cmd
}

View File

@ -0,0 +1,48 @@
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) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
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.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)))
})
}

View File

@ -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 <name>",
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("delete group: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted group %s!\n", cliui.Styles.Keyword.Render(group.Name))
return nil
},
}
return cmd
}

View File

@ -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)
})
}

113
enterprise/cli/groupedit.go Normal file
View File

@ -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 <name>",
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
}

View File

@ -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)
})
}

View File

@ -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 <name>\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)
}

View File

@ -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 <name>")
})
}

23
enterprise/cli/groups.go Normal file
View File

@ -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
}

View File

@ -11,6 +11,7 @@ func enterpriseOnly() []*cobra.Command {
server(),
features(),
licenses(),
groups(),
}
}

View File

@ -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) {

View File

@ -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,
}

View File

@ -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()