mirror of https://github.com/coder/coder.git
feat: add groups support to the CLI (#4755)
This commit is contained in:
parent
ce2a7d49b1
commit
90f77a3415
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)))
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>")
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -11,6 +11,7 @@ func enterpriseOnly() []*cobra.Command {
|
|||
server(),
|
||||
features(),
|
||||
licenses(),
|
||||
groups(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue